Component library
Back to catalog

Icon stat band

A navy band over a dimmed road photo with four stats: orange line icon, a big white figure that counts up from zero, and a label. The row rises 40px into view; stacks to one centred column on a phone.

statscounterbandiconsappear

Open preview in a new tab

Use this component

Three ways to get Icon stat band 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 2 files 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 Icon stat band from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/icon-stat-band/

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 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.

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
“Icon stat band” from my Astro Component Library:
https://astro.baysixmedia.com/components/icon-stat-band/

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
2 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/IconStatBand.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/icon-stat-band/highway-dusk.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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>