Key metrics banner
A band of figures between two sections, counted up in the format of the language: a full-width band in three tones, live figures with trend lines, figures against their goals and one sentence that carries the figures.
Figures between two sections
A metrics band puts a few strong figures between two sections, a pause that proves what the page just claimed. Every figure counts in the format of the page language, and with reduced motion it stands still at its final value.
Full-width band sets four figures between hairlines on a band in the accent, dark or surface tone, with a soft light in one corner, the figures count up one after another. Live figures runs on a dark band with a live dot, the figures keep rising calmly while the page is open and a small trend line sits under each. Against the goal measures figures against their goals, the bars fill one after another when the band comes into view and reached goals turn green and say so. Figures in a sentence writes one large sentence that carries the figures, each figure counts where it stands and a stroke draws itself underneath.
Use three or four figures, more turn a band into a table. Choose figures a customer cares about and round them honestly. Live figures should be real, if a number only moves for effect, the band in a quiet tone does the job better.
Variants
Details
What Metrics band brings along, read from the registry and the source.
- Category
- Stats
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/metrics-band.tsx
- Brings along
- utilskitnumber-tickersection
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MetricsBandProps interface from components/ui/metrics-band.tsx, with the comments from the code.
variantoptionalMetricsBandVariantbandliveprogressstatement
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodemetricsrequiredMetric[]toneoptional'accent' | 'dark' | 'surface'band: the tone of the band
sentenceoptionalstringstatement: the sentence, {0}, {1} … stand for the metrics in order
noteoptionalstringA source line under the band
Built-in texts
Metrics band writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| live | English Live | German Live |
| updated | English Updated every few seconds | German Alle paar Sekunden aktualisiert |
| of | English of {g} | German von {g} |
| reached | English Goal reached | German Ziel erreicht |
Accessibility
Read from the source of the component.
- Sets aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 329 lines
'use client';
import { useEffect, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Eyebrow } from '@/components/ui/section';
/*
* A band of figures between two sections, set large and counted up in the
* format of the language. Four layouts:
*
* band a full-width band in the accent, dark or surface tone, figures between hairlines
* live figures that keep rising while the page is open, each with a small trend line
* progress figures against their goals, bars that fill when the band comes into view
* statement one sentence with the figures inside it, each counting where it stands
*/
export type MetricsBandVariant = 'band' | 'live' | 'progress' | 'statement';
export interface Metric {
value: number;
prefix?: string;
suffix?: string;
/** Fixed decimals, e.g. 1 for 4.8 */
decimals?: number;
label: string;
/** A short second line */
detail?: string;
/** progress: the goal the value is measured against */
goal?: number;
/** live: recent values for the trend line */
trend?: number[];
/** live: how much the value grows per minute while the page is open */
perMinute?: number;
}
export interface MetricsBandProps {
variant?: MetricsBandVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
metrics: Metric[];
/** band: the tone of the band */
tone?: 'accent' | 'dark' | 'surface';
/** statement: the sentence, {0}, {1} … stand for the metrics in order */
sentence?: string;
/** A source line under the band */
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: { live: 'Live', updated: 'Updated every few seconds', of: 'of {g}', reached: 'Goal reached' },
de: { live: 'Live', updated: 'Alle paar Sekunden aktualisiert', of: 'von {g}', reached: 'Ziel erreicht' },
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = MetricsBandProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
function useFormat() {
const locale = useLocale();
return (n: number, d = 0) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: d, maximumFractionDigits: d }).format(n);
}
function Figure({ m, delay: ms = 0, className }: { m: Metric; delay?: number; className?: string }) {
const d = m.decimals ?? 0;
return (
<span className={cn('tabular-nums', className)}>
{m.prefix}
<NumberTicker value={m.value} delay={ms} format={{ minimumFractionDigits: d, maximumFractionDigits: d }} />
{m.suffix}
</span>
);
}
function Head({ eyebrow, title, description, center, className }: Pick<MetricsBandProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
if (!title && !eyebrow) return null;
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* band */
/* ------------------------------------------------------------------ */
function Band(p: ViewProps) {
const tone = p.tone ?? 'accent';
const on = tone === 'accent';
const n = p.metrics.length;
return (
<section className={cn('relative overflow-hidden py-14 sm:py-16', tone === 'accent' && 'bg-primary text-primary-foreground', tone === 'dark' && 'dark bg-bezel text-foreground', tone === 'surface' && 'bg-surface text-foreground', p.className)}>
{/* a slow light across the band */}
<div aria-hidden className={cn('pointer-events-none absolute inset-0 opacity-60', on ? 'bg-[radial-gradient(40%_120%_at_15%_0%,color-mix(in_oklch,var(--primary-foreground)_16%,transparent),transparent_70%)]' : 'bg-[radial-gradient(40%_120%_at_15%_0%,var(--glow),transparent_70%)]')} />
<div className="kit-container relative">
{(p.title || p.eyebrow) && (
<div className="mb-10 max-w-[40rem]">
{p.eyebrow && <p className={cn('mb-3 text-sm font-medium', on ? 'text-[color-mix(in_oklch,var(--primary-foreground)_75%,transparent)]' : 'text-muted-foreground')}>{p.eyebrow}</p>}
{p.title && <h2 className="kit-rise text-balance text-[clamp(1.5rem,1.2rem+1vw,2rem)] font-semibold leading-tight tracking-[-0.02em]">{p.title}</h2>}
</div>
)}
<dl className={cn('grid grid-cols-2 gap-y-10', n >= 4 ? 'lg:grid-cols-4' : n === 3 ? 'lg:grid-cols-3' : '')}>
{p.metrics.map((m, i) => (
<div
key={i}
className={cn(
'kit-rise flex min-w-0 flex-col pr-4 sm:pr-6',
on ? 'border-[color-mix(in_oklch,var(--primary-foreground)_22%,transparent)]' : 'border-border',
i % 2 === 1 && 'border-l pl-4 sm:pl-6',
n >= 3 && i > 0 && 'lg:border-l lg:pl-8',
)}
style={delay(i * 90)}
>
<dt className={cn('order-2 mt-2 text-pretty text-[14px] font-medium', on ? 'text-[color-mix(in_oklch,var(--primary-foreground)_85%,transparent)]' : 'text-foreground')}>{m.label}</dt>
<dd className="order-1 text-[clamp(2.25rem,1.7rem+2.2vw,3.5rem)] font-semibold leading-none tracking-[-0.045em]">
<Figure m={m} delay={i * 120} />
</dd>
{m.detail && <dd className={cn('order-3 mt-1 text-pretty text-[13px]', on ? 'text-[color-mix(in_oklch,var(--primary-foreground)_65%,transparent)]' : 'text-muted-foreground')}>{m.detail}</dd>}
</div>
))}
</dl>
{p.note && <p className={cn('mt-10 text-[12.5px]', on ? 'text-[color-mix(in_oklch,var(--primary-foreground)_65%,transparent)]' : 'text-subtle-foreground')}>{p.note}</p>}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* live */
/* ------------------------------------------------------------------ */
function Spark({ values, className }: { values: number[]; className?: string }) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const W = 120;
const H = 32;
const pts = values.map((v, i) => [round((i / (values.length - 1)) * W), round(H - 3 - ((v - min) / Math.max(1e-9, max - min)) * (H - 6))]);
const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');
return (
<svg viewBox={`0 0 ${W} ${H}`} aria-hidden className={cn('h-8 w-full overflow-visible', className)} preserveAspectRatio="none">
<path d={`${d} L${W} ${H} L0 ${H} Z`} fill="color-mix(in oklab, var(--primary) 14%, transparent)" />
<path d={d} fill="none" stroke="var(--primary)" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="3" fill="var(--primary)" className="animate-kit-pulse motion-reduce:animate-none" />
</svg>
);
}
function LiveValue({ m, running }: { m: Metric; running: boolean }) {
const format = useFormat();
const [extra, setExtra] = useState(0);
useEffect(() => {
if (!running || !m.perMinute || prefersReducedMotion()) return;
const step = m.perMinute / 20;
const id = window.setInterval(() => setExtra((x) => x + step * (0.6 + Math.random() * 0.8)), 3000);
return () => window.clearInterval(id);
}, [running, m.perMinute]);
const d = m.decimals ?? 0;
if (!extra) return <Figure m={m} />;
return (
<span className="tabular-nums">
{m.prefix}
<span key={Math.floor(extra * 10 ** d)} className="inline-block animate-[mb-tick_.5s_var(--ease-kit)] motion-reduce:animate-none">
{format(m.value + extra, d)}
</span>
{m.suffix}
</span>
);
}
function Live(p: ViewProps) {
const { t } = p;
const [ref, inView] = useInView<HTMLDivElement>();
return (
<section className={cn('dark relative overflow-hidden bg-bezel py-16 text-foreground sm:py-20', p.className)}>
<div aria-hidden className="kit-grid pointer-events-none absolute inset-0 opacity-40 [mask-image:radial-gradient(70%_80%_at_50%_50%,black,transparent)]" />
<div className="kit-container relative">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<Head {...p} />
<p className="flex shrink-0 items-center gap-2 text-[13px] text-muted-foreground">
<span className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-success opacity-70 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-success" />
</span>
<span className="font-semibold text-foreground">{t.live}</span> · {t.updated}
</p>
</div>
<div ref={ref} className={cn('grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-border bg-border', p.metrics.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3', (p.title || p.eyebrow) && 'mt-10')}>
{p.metrics.map((m, i) => (
<div key={i} className="min-w-0 bg-card p-4 sm:p-6">
<div className="kit-rise" style={delay(i * 80)}>
<p className="min-h-[2lh] text-[13px] font-medium leading-snug text-muted-foreground sm:min-h-0">{m.label}</p>
<p aria-live="off" className="mt-3 text-[clamp(2rem,1.6rem+1.4vw,2.75rem)] font-semibold leading-none tracking-[-0.04em] text-foreground">
<LiveValue m={m} running={inView} />
</p>
{m.trend && <Spark values={m.trend} className="mt-5" />}
{m.detail && <p className="mt-3 text-[12.5px] text-subtle-foreground">{m.detail}</p>}
</div>
</div>
))}
</div>
{p.note && <p className="mt-6 text-[12.5px] text-subtle-foreground">{p.note}</p>}
</div>
<style>{'@keyframes mb-tick{from{opacity:.35;transform:translateY(-30%)}}'}</style>
</section>
);
}
/* ------------------------------------------------------------------ */
/* progress */
/* ------------------------------------------------------------------ */
function Progress(p: ViewProps) {
const { t } = p;
const format = useFormat();
const [ref, inView] = useInView<HTMLDivElement>();
return (
<section className={cn('py-20 sm:py-24', p.className)}>
<div className="kit-container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-20">
<Head {...p} />
<div ref={ref} className={cn('grid gap-8', !p.title && 'lg:col-span-2')}>
{p.metrics.map((m, i) => {
const share = m.goal ? Math.min(1, m.value / m.goal) : 1;
const done = m.goal !== undefined && m.value >= m.goal;
return (
<div key={i} className="kit-rise" style={delay(i * 90)}>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<p className="text-[15px] font-medium text-foreground">{m.label}</p>
<p className="flex items-baseline gap-2">
<span className="text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground">
<Figure m={m} delay={i * 120} />
</span>
{m.goal !== undefined && (
<span className="text-[13px] tabular-nums text-muted-foreground">
{fill(t.of, { g: `${m.prefix ?? ''}${format(m.goal, m.decimals ?? 0)}${m.suffix ?? ''}` })}
</span>
)}
</p>
</div>
<div className="relative mt-3 h-2.5 overflow-hidden rounded-full bg-track">
<span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none', done ? 'bg-success' : 'bg-primary')}
style={{ width: `${inView ? round(share * 100, 2) : 0}%`, transitionDelay: `${i * 120}ms` }}
/>
</div>
{(m.detail || done) && (
<p className={cn('mt-2 flex items-center gap-1.5 text-[13px]', done ? 'text-success-foreground' : 'text-muted-foreground')}>
{done && <span aria-hidden className="size-1.5 rounded-full bg-success" />}
{done ? t.reached : m.detail}
{done && m.detail && <span className="text-muted-foreground"> · {m.detail}</span>}
</p>
)}
</div>
);
})}
{p.note && <p className="text-[12.5px] text-subtle-foreground">{p.note}</p>}
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* statement */
/* ------------------------------------------------------------------ */
function Statement(p: ViewProps) {
const [ref, inView] = useInView<HTMLParagraphElement>('0px 0px -20% 0px');
const parts = (p.sentence ?? '').split(/\{(\d+)\}/g);
return (
<section className={cn('kit-glow relative overflow-hidden py-24 sm:py-32', p.className)}>
<div className="kit-container">
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<p ref={ref} className="max-w-[60rem] text-balance text-[clamp(1.75rem,1.2rem+2.4vw,3.25rem)] font-semibold leading-[1.18] tracking-[-0.03em] text-muted-foreground">
{parts.map((s, i) => {
if (i % 2 === 0) return <span key={i}>{s}</span>;
const m = p.metrics[Number(s)];
if (!m) return null;
return (
<span key={i} className="relative inline-block whitespace-nowrap text-foreground">
<Figure m={m} delay={Number(s) * 200} />
{/* a stroke under each figure that draws itself */}
<span aria-hidden className="absolute inset-x-0 -bottom-0.5 h-[0.08em] origin-left rounded-full bg-primary transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? 1 : 0})`, transitionDelay: `${300 + Number(s) * 200}ms` }} />
</span>
);
})}
</p>
<dl className="mt-14 grid grid-cols-2 gap-x-6 gap-y-6 border-t border-border pt-8 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]">
{p.metrics.map((m, i) => (
<div key={i} className="kit-rise" style={delay(i * 80)}>
<dt className="text-[13px] text-muted-foreground">{m.label}</dt>
{m.detail && <dd className="mt-1 text-pretty text-[14px] font-medium text-foreground">{m.detail}</dd>}
</div>
))}
</dl>
{p.note && <p className="mt-8 text-[12.5px] text-subtle-foreground">{p.note}</p>}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<MetricsBandVariant, (p: ViewProps) => React.ReactNode> = {
band: Band,
live: Live,
progress: Progress,
statement: Statement,
};
export function MetricsBand({ variant = 'band', labels, ...rest }: MetricsBandProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? Band;
return <View {...rest} variant={variant} t={t} />;
}Frequent questions
How do I install Metrics band?
With aiw add @ai-website/metrics-band. The command puts components/ui/metrics-band.tsx into your project, the code is yours from then on. It brings along number-ticker and section. It needs no further npm packages.
Which variants does Metrics band have?
4 variants, Full-width band, Live figures, Against the goal and Figures in a sentence. You pick one with the variant prop, for example variant="live".
Can I translate the texts of Metrics band?
Yes. Metrics band carries 4 texts of its own in English and German, such as “Live”, “Updated every few seconds” and “Goal reached”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Metrics band?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Metrics band with aiw_components and install it with aiw_add @ai-website/metrics-band. Name the variant in your prompt, for example variant="band", and the assistant writes your content straight into the props.