Three ways to get Metric statement into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into 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 the branding and content open.
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.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Metric statement
Library page: https://astro.baysixmedia.com/components/metric-statement
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Metric statement from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/metric-statement
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
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.
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. */
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;
}
const defaultMetrics: Metric[] = [
{ value: 40, suffix: "+", label: "Variants retired" },
{ value: 12, label: "Tokens in the palette" },
{ value: 3, suffix: "×", label: "Faster review cycles" },
{ value: 98, suffix: "%", label: "Contrast checks passing" },
];
const {
heading = "Most teams do not need more parts. They need fewer decisions.",
description = "Every component in this catalog replaced a handful of near-identical ones that had quietly drifted apart. Fewer parts means fewer arguments, shorter reviews, and a smaller surface to keep accessible. The team you already have ships more, because less is in the way.",
metrics = defaultMetrics,
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}` : "");
};
---
<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>
)
}
{
metrics.length > 0 && (
<div class="metrics" data-lift>
{metrics.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>