Kennzahlen-Banner
Ein Band aus Zahlen zwischen zwei Abschnitten, hochgezählt im Format der Sprache: ein breites Band in drei Tönen, Live-Zahlen mit Verlaufslinien, Zahlen gegen ihre Ziele und ein Satz, der die Zahlen trägt.
Zahlen zwischen zwei Sections
Ein Kennzahlen-Band setzt ein paar starke Zahlen zwischen zwei Sections, eine Pause, die belegt, was die Seite gerade behauptet hat. Jede Zahl zählt im Format der Seitensprache, und bei reduzierter Bewegung steht sie sofort auf ihrem Endwert.
Das „Breite Band“ setzt vier Zahlen zwischen Haarlinien auf ein Band in Akzent, Dunkel oder Fläche, mit weichem Licht in einer Ecke, die Zahlen zählen nacheinander hoch. „Live-Zahlen“ läuft auf einem dunklen Band mit Live-Punkt, die Zahlen steigen ruhig weiter, solange die Seite offen ist, und unter jeder steht eine kleine Verlaufslinie. „Gegen das Ziel“ misst Zahlen an ihren Zielen, die Balken füllen sich nacheinander, sobald das Band in den Blick kommt, erreichte Ziele werden grün und sagen es. „Zahlen im Satz“ schreibt einen großen Satz, der die Zahlen trägt, jede Zahl zählt an ihrer Stelle hoch und darunter zeichnet sich ein Strich.
Nimm drei oder vier Zahlen, mehr machen aus dem Band eine Tabelle. Wähle Zahlen, die Kunden wichtig sind, und runde ehrlich. Live-Zahlen sollten echt sein, bewegt sich eine Zahl nur als Effekt, wirkt das Band in ruhigem Ton besser.
Variants
Details
Was Kennzahlen-Band mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Stats
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/metrics-band.tsx
- Bringt mit
- utilskitnumber-tickersection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MetricsBandProps aus components/ui/metrics-band.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMetricsBandVariantbandliveprogressstatement
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodemetricsPflichtMetric[]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
Eingebaute Texte
Diese Texte schreibt Kennzahlen-Band selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 4 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| live | Englisch Live | Deutsch Live |
| updated | Englisch Updated every few seconds | Deutsch Alle paar Sekunden aktualisiert |
| of | Englisch of {g} | Deutsch von {g} |
| reached | Englisch Goal reached | Deutsch Ziel erreicht |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 329 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Kennzahlen-Band?
Mit aiw add @ai-website/metrics-band. Der Befehl legt die Datei components/ui/metrics-band.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind number-ticker und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kennzahlen-Band?
4 Varianten, Breites Band, Live-Zahlen, Gegen das Ziel und Zahlen im Satz. Du wählst sie mit der Prop variant, zum Beispiel variant="live".
Kann ich die Texte von Kennzahlen-Band übersetzen?
Ja. Kennzahlen-Band bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Live“, „Alle paar Sekunden aktualisiert“ und „Ziel erreicht“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kennzahlen-Band einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kennzahlen-Band mit aiw_components und installieren sie mit aiw_add @ai-website/metrics-band. Nenn im Prompt die Variante, etwa variant="band", dann schreibt der Assistent deine Inhalte direkt in die Props.