Component library
Back to catalog

Metric statement

A centred statement over a row of metrics that count up when the section scrolls into view.

sectionmetricsanimation

Open preview in a new tab

Use this component

Three ways to get Metric statement 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 Metric statement from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/metric-statement/

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: "Metric statement"
Library page: https://astro.baysixmedia.com/components/metric-statement/
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/MetricStatement.astro =====
---
/**
 * MetricStatement — a centred statement over a row of metrics that count up
 * when the section arrives.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * The ambient field behind the text is drawn with CSS gradients rather than
 * loaded, so the section has nothing to download and still has depth.
 *
 * Measured behaviour:
 *   section       min-height 100vh, centred, generous vertical padding
 *   statement     max 800px, centred at >=768 and left-aligned below
 *   metrics       one row at >=768, two columns below
 *   count-up      1100ms, ease-out-expo, once, at 60% visibility
 *   reveal        opacity 500ms + 12px lift 550ms, cubic-bezier(0.16, 1, 0.3, 1)
 *
 * Several instances can share a page: each root gets its own observers, and
 * every counter closes over its own element, so two statements never drive
 * each other's numbers.
 */

export interface Metric {
	/** The number counted to. Rendered in full before the count starts. */
	value: number;
	/** Small text before the number, e.g. "$". */
	prefix?: string;
	/** Small text after the number, e.g. "+", "%" or "×". */
	suffix?: string;
	/** Decimal places held throughout the count. */
	decimals?: number;
	/** The line under the number. */
	label: string;
}

interface Props {
	/** The statement. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	description?: string;
	/**
	 * The metrics. Four reproduce the tested rhythm; any count works. One
	 * without a finite value and a label is dropped. With no heading, no
	 * description and no metrics the component renders nothing.
	 */
	metrics?: Metric[];
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Milliseconds each number takes to count. `0` turns counting off. */
	countMs?: number;
	/** Thousands separator. `""` renders 1200 rather than 1,200. */
	groupSeparator?: string;
	/** Fill the viewport. Off lets the section be only as tall as its content. */
	fullHeight?: boolean;
	/** Section background. */
	surface?: string;
	/** Heading and number colour. The muted tone is derived from it. */
	ink?: string;
	/** Tint of the ambient field behind the text. */
	glow?: string;
	/** Draw the ambient field at all. */
	ambient?: boolean;
	/** Fade and lift the three blocks in as they arrive. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

/* No sample statement or figures: every number here is a claim, so it is the caller's. */
const {
	heading = "",
	description = "",
	metrics = [],
	headingLevel = "h2",
	countMs = 1100,
	groupSeparator = ",",
	fullHeight = true,
	surface = "#08090a",
	ink = "#ecedef",
	glow = "#6f7cff",
	ambient = true,
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Formatting happens here as well as in the script, and the two must agree —
 * the server renders the finished number so that a reader without scripting,
 * or one who arrives before the count runs, sees the real figure rather than
 * a zero. The script only reaches for it once it is about to animate.
 */
const group = (whole: string) =>
	groupSeparator ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator) : whole;

const format = (n: number, decimals: number) => {
	const fixed = Math.abs(n).toFixed(decimals);
	const [whole, fraction] = fixed.split(".");
	return (n < 0 ? "-" : "") + group(whole) + (fraction ? `.${fraction}` : "");
};

/* A metric needs both a finite number and a label; a partial one is dropped. */
const figures = metrics.filter(
	(m) =>
		m &&
		typeof m.value === "number" &&
		Number.isFinite(m.value) &&
		typeof m.label === "string" &&
		m.label.trim() !== "",
);
const hasContent = !!heading || !!description || figures.length > 0;
---

{hasContent && (
<section
	class:list={["statement", className]}
	id={id}
	data-metric-statement
	data-count-ms={String(countMs)}
	data-reveal={reveal ? "" : undefined}
	data-full={fullHeight ? "" : undefined}
	style={`--ms-surface:${surface};--ms-ink:${ink};--ms-glow:${glow};`}
>
	{
		ambient && (
			<div class="ambient" aria-hidden="true">
				<span class="field field-a" />
				<span class="field field-b" />
			</div>
		)
	}

	<div class="inner">
		{
			(heading || description) && (
				<div class="text">
					{heading && (
						<Heading class="heading" data-lift>
							{heading}
						</Heading>
					)}
					{description && (
						<p class="description" data-lift>
							{description}
						</p>
					)}
				</div>
			)
		}

		{
			figures.length > 0 && (
				<div class="metrics" data-lift>
					{figures.map((metric) => {
						const decimals = metric.decimals ?? 0;
						return (
							<div class="metric">
								<span
									class="value"
									data-count
									data-to={String(metric.value)}
									data-decimals={String(decimals)}
									data-prefix={metric.prefix ?? ""}
									data-suffix={metric.suffix ?? ""}
									data-separator={groupSeparator}
								>
									{metric.prefix ?? ""}
									{format(metric.value, decimals)}
									{metric.suffix ?? ""}
								</span>
								<span class="label">{metric.label}</span>
							</div>
						);
					})}
				</div>
			)
		}
	</div>
</section>
)}

<style>
	.statement {
		/* Both tones are derived from --ms-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--ms-muted: color-mix(in srgb, var(--ms-ink) 56%, transparent);

		--ms-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 32px at a 390px viewport, 56px from about 1440px up. */
		--ms-display: clamp(2rem, 1.3rem + 2.5vw, 3.5rem);
		--ms-value: clamp(3rem, 2.2rem + 2vw, 4rem);
		--ms-body: clamp(1rem, 0.9rem + 0.35vw, 1.25rem);
		--ms-label: 1rem;

		--ms-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		position: relative;
		display: flex;
		justify-content: center;
		align-items: center;
		overflow: hidden;
		isolation: isolate;
		padding: 128px 24px;
		background: var(--ms-surface);
		color: var(--ms-ink);
		font-family: var(--ms-font);
	}

	.statement :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.statement[data-full] {
		min-height: 100vh;
		min-height: 100svh;
	}

	/* ---------- ambient field ----------
	 * Two very soft gradients, drawn rather than downloaded. They drift on a
	 * long loop so the ground is never quite still; the drift is removed
	 * under a reduced-motion preference.
	 */

	.ambient {
		position: absolute;
		inset: 0;
		z-index: 0;
		pointer-events: none;
		overflow: hidden;
	}

	.field {
		position: absolute;
		display: block;
		border-radius: 50%;
		will-change: transform;
	}

	.field-a {
		top: -30%;
		left: -15%;
		width: 90%;
		height: 120%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--ms-glow) 0%,
			transparent 70%
		);
		opacity: 0.1;
		animation: ms-drift-a 34s ease-in-out infinite alternate;
	}

	.field-b {
		right: -20%;
		bottom: -35%;
		width: 85%;
		height: 115%;
		background: radial-gradient(
			ellipse farthest-side at center,
			color-mix(in srgb, var(--ms-ink) 70%, var(--ms-glow)) 0%,
			transparent 70%
		);
		opacity: 0.07;
		animation: ms-drift-b 42s ease-in-out infinite alternate;
	}

	@keyframes ms-drift-a {
		from {
			transform: translate3d(-4%, -2%, 0) scale(1);
		}
		to {
			transform: translate3d(6%, 4%, 0) scale(1.12);
		}
	}

	@keyframes ms-drift-b {
		from {
			transform: translate3d(3%, 3%, 0) scale(1.08);
		}
		to {
			transform: translate3d(-5%, -3%, 0) scale(1);
		}
	}

	.inner {
		position: relative;
		z-index: 1;
		width: 100%;
		max-width: 1280px;
	}

	/* ---------- statement ---------- */

	.text {
		max-width: 800px;
		margin-inline: auto;
	}

	.heading {
		margin: 0;
		font-size: var(--ms-display);
		line-height: 0.95;
		font-weight: 550;
		letter-spacing: -0.02em;
		text-wrap: balance;
	}

	.description {
		margin: 32px 0 0;
		font-size: var(--ms-body);
		line-height: 1.5;
		font-weight: 450;
		letter-spacing: -0.02em;
		color: var(--ms-muted);
		text-wrap: pretty;
	}

	/* ---------- metrics ---------- */

	.metrics {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 32px 24px;
		margin-top: 48px;
	}

	.metric {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		min-width: 0;
	}

	.value {
		font-size: var(--ms-value);
		line-height: 1;
		font-weight: 550;
		letter-spacing: -0.02em;
		/* Tabular figures stop the row jittering while the numbers run. */
		font-variant-numeric: tabular-nums;
	}

	.label {
		font-size: var(--ms-label);
		line-height: 1.2;
		font-weight: 450;
		letter-spacing: -0.02em;
		color: var(--ms-muted);
		text-wrap: pretty;
	}

	/* ---------- reveal ----------
	 * Hidden in CSS rather than armed by script, so the first paint is already
	 * correct and nothing flashes visible then snaps away. The reduced-motion
	 * and <noscript> escapes below cover the cases where it must not apply.
	 */

	.statement[data-reveal] [data-lift] {
		opacity: 0;
		transform: translateY(12px);
		transition:
			opacity 500ms linear,
			transform 550ms var(--ms-ease);
	}

	.statement[data-reveal] [data-lift][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.statement {
			padding: 176px 48px;
		}

		.text {
			text-align: center;
		}

		.description {
			margin-top: 48px;
		}

		/*
		 * Flex rather than a fixed column count: the row centres itself for any
		 * number of metrics, and wraps instead of overflowing when there are
		 * more of them than the width can hold.
		 */
		.metrics {
			display: flex;
			flex-wrap: wrap;
			justify-content: center;
			gap: 48px 64px;
			margin-top: 96px;
		}
	}

	@media (min-width: 1200px) {
		.statement {
			padding: 192px 64px;
		}

		.metrics {
			gap: 48px 128px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.statement[data-reveal] [data-lift] {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.field-a,
		.field-b {
			animation: none;
		}
	}
</style>

<noscript>
	<style>
		.statement[data-reveal] [data-lift] {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Two behaviours, both built per instance so several statements on a page
	 * never drive each other: the entrance lift, and the count-up.
	 */

	const prefersReduced = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	/* ---- entrance lift ---- */

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const blocks = [...root.querySelectorAll<HTMLElement>("[data-lift]")];
		if (blocks.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			blocks.forEach((block) => block.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The large top margin is what makes a jump safe. An observer
			 * reports only a *change* in intersection, so a block that goes
			 * from below the viewport to above it in one jump — an anchor link,
			 * a restored scroll position — would read 0 both times, never fire,
			 * and stay invisible for good. Extending the root upwards means
			 * "already scrolled past" still counts as intersecting.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		blocks.forEach((block) => observer.observe(block));
	};

	/* ---- count-up ---- */

	/** Mirrors the formatting done at render time, separator included. */
	const formatValue = (n: number, decimals: number, separator: string) => {
		const fixed = Math.abs(n).toFixed(decimals);
		const [whole, fraction] = fixed.split(".");
		const grouped = separator
			? whole.replace(/\B(?=(\d{3})+(?!\d))/g, separator)
			: whole;
		return (n < 0 ? "-" : "") + grouped + (fraction ? `.${fraction}` : "");
	};

	/* 1 - 2^(-10t): fast off the line, long settle. */
	const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));

	const setupCounters = (root: HTMLElement) => {
		const counters = [...root.querySelectorAll<HTMLElement>("[data-count]")];
		if (counters.length === 0) return;

		const duration = Number(root.dataset.countMs ?? "1100");

		/*
		 * The markup already holds the finished number. If the count cannot or
		 * should not run — no observer, no motion wanted, no duration — the
		 * right move is to leave it alone rather than zero it first.
		 */
		if (
			!Number.isFinite(duration) ||
			duration <= 0 ||
			prefersReduced() ||
			!("IntersectionObserver" in window)
		) {
			return;
		}

		const run = (el: HTMLElement) => {
			const target = Number(el.dataset.to);
			if (!Number.isFinite(target)) return;

			const decimals = Number(el.dataset.decimals ?? "0") || 0;
			const prefix = el.dataset.prefix ?? "";
			const suffix = el.dataset.suffix ?? "";
			const separator = el.dataset.separator ?? "";

			const paint = (n: number) =>
				(el.textContent = prefix + formatValue(n, decimals, separator) + suffix);

			let started: number | null = null;
			const frame = (now: number) => {
				if (started === null) started = now;
				const t = Math.min((now - started) / duration, 1);
				paint(easeOutExpo(t) * target);
				if (t < 1) requestAnimationFrame(frame);
			};

			paint(0);
			requestAnimationFrame(frame);
		};

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					run(entry.target as HTMLElement);
					/* Counting is one-way: a number that has run stays run. */
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.6 },
		);

		counters.forEach((counter) => observer.observe(counter));
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-metric-statement]")) {
		setupReveal(root);
		setupCounters(root);
	}
</script>

===== END src/components/MetricStatement.astro =====

===== FILE (supporting): src/components/MetricStatement.md =====
# Metric statement

A centred statement — heading and paragraph — over a row of metrics whose
numbers count up when the section scrolls into view. The section fills the
viewport by default and paints its own ambient field behind the text.

Catalog page: `/components/metric-statement`

## Copying it into another Astro project

Copy **`MetricStatement.astro`**. That is the entire component — one file, no
second step.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--ms-*` property on the root |
| Webfonts | none — system stack |
| Images, video, icons, fonts | **none** |
| Network requests | **none** |
| Host requirement | Astro compiles `.astro` files |

The ambient field behind the text is drawn with two CSS radial gradients, so
there is nothing to download and nothing to copy alongside the file.

### Exact steps

1. Copy `MetricStatement.astro` into your components folder, for example
   `src/components/MetricStatement.astro`.
2. Import it in any `.astro` page and render it:

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

<MetricStatement
  heading="Most teams do not need more parts. They need fewer decisions."
  description="Fewer parts means fewer arguments and shorter reviews."
  metrics={[
    { value: 40, suffix: "+", label: "Variants retired" },
    { value: 12, label: "Tokens in the palette" },
    { value: 98, suffix: "%", label: "Contrast checks passing" },
  ]}
/>
```

3. There is no step three. Every prop is optional, but the content props are
   empty by default — the component ships no sample statement or figures, since
   every number would be a claim. With no heading, no description and no
   metrics it renders nothing. A metric needs a finite `value` and a `label`;
   a partial one is dropped. The four-metric demo on the catalog page is
   passed as props.

The section paints its own background edge to edge and sets `min-height: 100vh`,
so it expects to be a full-width block on the page rather than sitting inside a
padded container. Pass `fullHeight={false}` if you want it only as tall as its
content.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `metrics` | `Metric[]` | `[]` | The metrics. Four reproduce the tested rhythm; any count works. Partial ones are dropped. |
| `heading` | `string` | `""` | The statement. Empty hides it. |
| `description` | `string` | `""` | Paragraph under the heading. Empty hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `countMs` | `number` | `1100` | Milliseconds each number takes. `0` turns counting off. |
| `groupSeparator` | `string` | `","` | Thousands separator. `""` renders `1200`. |
| `fullHeight` | `boolean` | `true` | Fill the viewport, rather than fitting the content. |
| `surface` | `string` | `"#08090a"` | Section background. |
| `ink` | `string` | `"#ecedef"` | Heading and number colour. The muted tone derives from it. |
| `glow` | `string` | `"#6f7cff"` | Tint of the ambient field. |
| `ambient` | `boolean` | `true` | Draw the ambient field at all. |
| `reveal` | `boolean` | `true` | Fade and lift the blocks in as they arrive. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |

### `Metric`

```ts
interface Metric {
  value: number;      // the number counted to
  prefix?: string;    // small text before it, e.g. "$"
  suffix?: string;    // small text after it, e.g. "+", "%", "×"
  decimals?: number;  // decimal places held throughout the count
  label: string;      // the line underneath
}
```

All four shapes work together — `{ value: 1200, suffix: "+" }` counts to
`1,200+`, `{ value: 1.5, decimals: 1, suffix: "s" }` counts to `1.5s`, and
`{ value: 42, prefix: "$", suffix: "k" }` counts to `$42k`.

**The number is typed, not parsed.** The value, its prefix, its suffix and its
decimals are separate fields rather than something read back out of the
rendered text. A text-parsing counter breaks the moment a number contains a
separator or a decimal point; this one does not.

## Layout

| | ≥1200 | 768–1199 | <768 |
| :--- | :--- | :--- | :--- |
| Section padding | 192px / 64px | 176px / 48px | 128px / 24px |
| Statement | centred, max 800px | centred, max 800px | **left aligned** |
| Metrics | one centred row | one centred row | **two columns** |
| Metric gap | 48px / 128px | 48px / 64px | 32px / 24px |
| Heading → paragraph | 48px | 48px | 32px |
| Paragraph → metrics | 96px | 96px | 48px |
| Heading | ~56px | ~42px | 32px |
| Number | ~64px | ~52px | 48px |
| Label | 16px | 16px | 16px |

Above `768px` the metrics are a centred flex row that **wraps** rather than a
fixed column count, so any number of them centres itself and falls to a second
line instead of overflowing. Below `768px` they become a two-column grid.

The numbers are set in `tabular-nums`, which keeps the row from jittering while
the digits change width mid-count.

## Behaviour

- **Count-up.** Each number runs from `0` to its value over `countMs`
  (default `1100`) on an ease-out expo curve, `1 − 2⁻¹⁰ᵗ` — fast off the line,
  long settle. It starts when that number is **60% visible** and runs once;
  the observer is dropped afterwards.
- **Entrance.** The heading, the paragraph and the metric row each fade in and
  rise `12px`, over `500ms` opacity and `550ms` transform on
  `cubic-bezier(0.16, 1, 0.3, 1)`. Each is observed separately.
- **Ambient field.** Two soft gradients drift on 34s and 42s loops so the
  ground is never quite still.
- **Several per page.** Each root gets its own observers and each counter
  closes over its own element, so two statements never drive each other's
  numbers and can run at different durations.

### The finished number is always in the HTML

The server renders the final figure — `40+`, not `0+`. The script only replaces
it once it is genuinely about to animate. So a reader who arrives with
scripting off, with `IntersectionObserver` missing, with `countMs={0}`, or
before the count triggers, sees the real number rather than a zero that never
moves. This is the one thing a count-up must not get wrong.

### Reduced motion

Under `prefers-reduced-motion: reduce`:

- **the numbers do not count.** They are rendered at their real values and left
  there — no zero, no run.
- the entrance fade and lift are removed; the blocks are simply visible.
- the ambient field stops drifting.

### Without JavaScript

The pre-reveal state is CSS keyed on `data-reveal`, so the first paint is
already correct and nothing flashes. A `<noscript>` block then makes the three
blocks visible. The numbers are already correct in the markup, so nothing is
lost.

## Notes on the reference

The layout idea — a centred statement over a row of counting metrics on a dark
ground — was studied from a live marketing page. **None of that site's content
came with it.** The demo's heading, paragraph, metric labels and every figure
(passed as props, not built in) are written for this catalog and are
**fictional sample numbers**; no
wording, statistic, colour, image or asset was copied. The reference drives its
backdrop with a 49-frame scroll-scrubbed image sequence; this component draws
its own in CSS and loads nothing.

Replace `heading`, `description` and `metrics` with your own and the design is
unchanged.

===== END src/components/MetricStatement.md =====

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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
“Metric statement” from my Astro Component Library:
https://astro.baysixmedia.com/components/metric-statement/

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.

src/components/library/MetricStatement.astro

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

---
/**
 * MetricStatement — a centred statement over a row of metrics that count up
 * when the section arrives.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * The ambient field behind the text is drawn with CSS gradients rather than
 * loaded, so the section has nothing to download and still has depth.
 *
 * Measured behaviour:
 *   section       min-height 100vh, centred, generous vertical padding
 *   statement     max 800px, centred at >=768 and left-aligned below
 *   metrics       one row at >=768, two columns below
 *   count-up      1100ms, ease-out-expo, once, at 60% visibility
 *   reveal        opacity 500ms + 12px lift 550ms, cubic-bezier(0.16, 1, 0.3, 1)
 *
 * Several instances can share a page: each root gets its own observers, and
 * every counter closes over its own element, so two statements never drive
 * each other's numbers.
 */

export interface Metric {
	/** The number counted to. Rendered in full before the count starts. */
	value: number;
	/** Small text before the number, e.g. "$". */
	prefix?: string;
	/** Small text after the number, e.g. "+", "%" or "×". */
	suffix?: string;
	/** Decimal places held throughout the count. */
	decimals?: number;
	/** The line under the number. */
	label: string;
}

interface Props {
	/** The statement. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	description?: string;
	/**
	 * The metrics. Four reproduce the tested rhythm; any count works. One
	 * without a finite value and a label is dropped. With no heading, no
	 * description and no metrics the component renders nothing.
	 */
	metrics?: Metric[];
	/** Heading level, for pages where h2 is wrong. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Milliseconds each number takes to count. `0` turns counting off. */
	countMs?: number;
	/** Thousands separator. `""` renders 1200 rather than 1,200. */
	groupSeparator?: string;
	/** Fill the viewport. Off lets the section be only as tall as its content. */
	fullHeight?: boolean;
	/** Section background. */
	surface?: string;
	/** Heading and number colour. The muted tone is derived from it. */
	ink?: string;
	/** Tint of the ambient field behind the text. */
	glow?: string;
	/** Draw the ambient field at all. */
	ambient?: boolean;
	/** Fade and lift the three blocks in as they arrive. */
	reveal?: boolean;
	class?: string;
	id?: string;
}

/* No sample statement or figures: every number here is a claim, so it is the caller's. */
const {
	heading = "",
	description = "",
	metrics = [],
	headingLevel = "h2",
	countMs = 1100,
	groupSeparator = ",",
	fullHeight = true,
	surface = "#08090a",
	ink = "#ecedef",
	glow = "#6f7cff",
	ambient = true,
	reveal = true,
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Formatting happens here as well as in the script, and the two must agree —
 * the server renders the finished number so that a reader without scripting,
 * or one who arrives before the count runs, sees the real figure rather than
 * a zero. The script only reaches for it once it is about to animate.
 */
const group = (whole: string) =>
	groupSeparator ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator) : whole;

const format = (n: number, decimals: number) => {
	const fixed = Math.abs(n).toFixed(decimals);
	const [whole, fraction] = fixed.split(".");
	return (n < 0 ? "-" : "") + group(whole) + (fraction ? `.${fraction}` : "");
};

/* A metric needs both a finite number and a label; a partial one is dropped. */
const figures = metrics.filter(
	(m) =>
		m &&
		typeof m.value === "number" &&
		Number.isFinite(m.value) &&
		typeof m.label === "string" &&
		m.label.trim() !== "",
);
const hasContent = !!heading || !!description || figures.length > 0;
---

{hasContent && (
<section
	class:list={["statement", className]}
	id={id}
	data-metric-statement
	data-count-ms={String(countMs)}
	data-reveal={reveal ? "" : undefined}
	data-full={fullHeight ? "" : undefined}
	style={`--ms-surface:${surface};--ms-ink:${ink};--ms-glow:${glow};`}
>
	{
		ambient && (
			<div class="ambient" aria-hidden="true">
				<span class="field field-a" />
				<span class="field field-b" />
			</div>
		)
	}

	<div class="inner">
		{
			(heading || description) && (
				<div class="text">
					{heading && (
						<Heading class="heading" data-lift>
							{heading}
						</Heading>
					)}
					{description && (
						<p class="description" data-lift>
							{description}
						</p>
					)}
				</div>
			)
		}

		{
			figures.length > 0 && (
				<div class="metrics" data-lift>
					{figures.map((metric) => {
						const decimals = metric.decimals ?? 0;
						return (
							<div class="metric">
								<span
									class="value"
									data-count
									data-to={String(metric.value)}
									data-decimals={String(decimals)}
									data-prefix={metric.prefix ?? ""}
									data-suffix={metric.suffix ?? ""}
									data-separator={groupSeparator}
								>
									{metric.prefix ?? ""}
									{format(metric.value, decimals)}
									{metric.suffix ?? ""}
								</span>
								<span class="label">{metric.label}</span>
							</div>
						);
					})}
				</div>
			)
		}
	</div>
</section>
)}

<style>
	.statement {
		/* Both tones are derived from --ms-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--ms-muted: color-mix(in srgb, var(--ms-ink) 56%, transparent);

		--ms-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 32px at a 390px viewport, 56px from about 1440px up. */
		--ms-display: clamp(2rem, 1.3rem + 2.5vw, 3.5rem);
		--ms-value: clamp(3rem, 2.2rem + 2vw, 4rem);
		--ms-body: clamp(1rem, 0.9rem + 0.35vw, 1.25rem);
		--ms-label: 1rem;

		--ms-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		position: relative;
		display: flex;
		justify-content: center;
		align-items: center;
		overflow: hidden;
		isolation: isolate;
		padding: 128px 24px;
		background: var(--ms-surface);
		color: var(--ms-ink);
		font-family: var(--ms-font);
	}

	.statement :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.statement[data-full] {
		min-height: 100vh;
		min-height: 100svh;
	}

	/* ---------- ambient field ----------
	 * Two very soft gradients, drawn rather than downloaded. They drift on a
	 * long loop so the ground is never quite still; the drift is removed
	 * under a reduced-motion preference.
	 */

	.ambient {
		position: absolute;
		inset: 0;
		z-index: 0;
		pointer-events: none;
		overflow: hidden;
	}

	.field {
		position: absolute;
		display: block;
		border-radius: 50%;
		will-change: transform;
	}

	.field-a {
		top: -30%;
		left: -15%;
		width: 90%;
		height: 120%;
		background: radial-gradient(
			ellipse farthest-side at center,
			var(--ms-glow) 0%,
			transparent 70%
		);
		opacity: 0.1;
		animation: ms-drift-a 34s ease-in-out infinite alternate;
	}

	.field-b {
		right: -20%;
		bottom: -35%;
		width: 85%;
		height: 115%;
		background: radial-gradient(
			ellipse farthest-side at center,
			color-mix(in srgb, var(--ms-ink) 70%, var(--ms-glow)) 0%,
			transparent 70%
		);
		opacity: 0.07;
		animation: ms-drift-b 42s ease-in-out infinite alternate;
	}

	@keyframes ms-drift-a {
		from {
			transform: translate3d(-4%, -2%, 0) scale(1);
		}
		to {
			transform: translate3d(6%, 4%, 0) scale(1.12);
		}
	}

	@keyframes ms-drift-b {
		from {
			transform: translate3d(3%, 3%, 0) scale(1.08);
		}
		to {
			transform: translate3d(-5%, -3%, 0) scale(1);
		}
	}

	.inner {
		position: relative;
		z-index: 1;
		width: 100%;
		max-width: 1280px;
	}

	/* ---------- statement ---------- */

	.text {
		max-width: 800px;
		margin-inline: auto;
	}

	.heading {
		margin: 0;
		font-size: var(--ms-display);
		line-height: 0.95;
		font-weight: 550;
		letter-spacing: -0.02em;
		text-wrap: balance;
	}

	.description {
		margin: 32px 0 0;
		font-size: var(--ms-body);
		line-height: 1.5;
		font-weight: 450;
		letter-spacing: -0.02em;
		color: var(--ms-muted);
		text-wrap: pretty;
	}

	/* ---------- metrics ---------- */

	.metrics {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 32px 24px;
		margin-top: 48px;
	}

	.metric {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		min-width: 0;
	}

	.value {
		font-size: var(--ms-value);
		line-height: 1;
		font-weight: 550;
		letter-spacing: -0.02em;
		/* Tabular figures stop the row jittering while the numbers run. */
		font-variant-numeric: tabular-nums;
	}

	.label {
		font-size: var(--ms-label);
		line-height: 1.2;
		font-weight: 450;
		letter-spacing: -0.02em;
		color: var(--ms-muted);
		text-wrap: pretty;
	}

	/* ---------- reveal ----------
	 * Hidden in CSS rather than armed by script, so the first paint is already
	 * correct and nothing flashes visible then snaps away. The reduced-motion
	 * and <noscript> escapes below cover the cases where it must not apply.
	 */

	.statement[data-reveal] [data-lift] {
		opacity: 0;
		transform: translateY(12px);
		transition:
			opacity 500ms linear,
			transform 550ms var(--ms-ease);
	}

	.statement[data-reveal] [data-lift][data-in] {
		opacity: 1;
		transform: none;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.statement {
			padding: 176px 48px;
		}

		.text {
			text-align: center;
		}

		.description {
			margin-top: 48px;
		}

		/*
		 * Flex rather than a fixed column count: the row centres itself for any
		 * number of metrics, and wraps instead of overflowing when there are
		 * more of them than the width can hold.
		 */
		.metrics {
			display: flex;
			flex-wrap: wrap;
			justify-content: center;
			gap: 48px 64px;
			margin-top: 96px;
		}
	}

	@media (min-width: 1200px) {
		.statement {
			padding: 192px 64px;
		}

		.metrics {
			gap: 48px 128px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.statement[data-reveal] [data-lift] {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.field-a,
		.field-b {
			animation: none;
		}
	}
</style>

<noscript>
	<style>
		.statement[data-reveal] [data-lift] {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * Two behaviours, both built per instance so several statements on a page
	 * never drive each other: the entrance lift, and the count-up.
	 */

	const prefersReduced = () =>
		window.matchMedia("(prefers-reduced-motion: reduce)").matches;

	/* ---- entrance lift ---- */

	const setupReveal = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const blocks = [...root.querySelectorAll<HTMLElement>("[data-lift]")];
		if (blocks.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			blocks.forEach((block) => block.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The large top margin is what makes a jump safe. An observer
			 * reports only a *change* in intersection, so a block that goes
			 * from below the viewport to above it in one jump — an anchor link,
			 * a restored scroll position — would read 0 both times, never fire,
			 * and stay invisible for good. Extending the root upwards means
			 * "already scrolled past" still counts as intersecting.
			 */
			{ rootMargin: "10000px 0px -10% 0px", threshold: 0 },
		);

		blocks.forEach((block) => observer.observe(block));
	};

	/* ---- count-up ---- */

	/** Mirrors the formatting done at render time, separator included. */
	const formatValue = (n: number, decimals: number, separator: string) => {
		const fixed = Math.abs(n).toFixed(decimals);
		const [whole, fraction] = fixed.split(".");
		const grouped = separator
			? whole.replace(/\B(?=(\d{3})+(?!\d))/g, separator)
			: whole;
		return (n < 0 ? "-" : "") + grouped + (fraction ? `.${fraction}` : "");
	};

	/* 1 - 2^(-10t): fast off the line, long settle. */
	const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));

	const setupCounters = (root: HTMLElement) => {
		const counters = [...root.querySelectorAll<HTMLElement>("[data-count]")];
		if (counters.length === 0) return;

		const duration = Number(root.dataset.countMs ?? "1100");

		/*
		 * The markup already holds the finished number. If the count cannot or
		 * should not run — no observer, no motion wanted, no duration — the
		 * right move is to leave it alone rather than zero it first.
		 */
		if (
			!Number.isFinite(duration) ||
			duration <= 0 ||
			prefersReduced() ||
			!("IntersectionObserver" in window)
		) {
			return;
		}

		const run = (el: HTMLElement) => {
			const target = Number(el.dataset.to);
			if (!Number.isFinite(target)) return;

			const decimals = Number(el.dataset.decimals ?? "0") || 0;
			const prefix = el.dataset.prefix ?? "";
			const suffix = el.dataset.suffix ?? "";
			const separator = el.dataset.separator ?? "";

			const paint = (n: number) =>
				(el.textContent = prefix + formatValue(n, decimals, separator) + suffix);

			let started: number | null = null;
			const frame = (now: number) => {
				if (started === null) started = now;
				const t = Math.min((now - started) / duration, 1);
				paint(easeOutExpo(t) * target);
				if (t < 1) requestAnimationFrame(frame);
			};

			paint(0);
			requestAnimationFrame(frame);
		};

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					run(entry.target as HTMLElement);
					/* Counting is one-way: a number that has run stays run. */
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.6 },
		);

		counters.forEach((counter) => observer.observe(counter));
	};

	for (const root of document.querySelectorAll<HTMLElement>("[data-metric-statement]")) {
		setupReveal(root);
		setupCounters(root);
	}
</script>