Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 2 Components in Stats

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

Breites BandNeu in 1.2

Vier Zahlen zwischen Haarlinien auf einem Band in Akzent, Dunkel oder Fläche, mit weichem Licht in einer Ecke. Die Zahlen zählen nacheinander hoch.

Live-ZahlenNeu in 1.2

Ein dunkles Band mit Live-Punkt, Zahlen, die ruhig weiter steigen, solange die Seite offen ist, und eine kleine Verlaufslinie unter jeder. Bei reduzierter Bewegung stehen sie still.

Gegen das ZielNeu in 1.2

Zahlen gemessen 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 SatzNeu in 1.2

Ein großer Satz, der die Zahlen trägt. Jede Zahl zählt an ihrer Stelle hoch, darunter zeichnet sich ein Strich, die Bezeichnungen stehen unten noch einmal ruhig.

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

Props

Die Schnittstelle MetricsBandProps aus components/ui/metrics-band.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MetricsBandVariant

    bandliveprogressstatement

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • metricsPflicht
    Metric[]
  • toneoptional
    'accent' | 'dark' | 'surface'

    band: the tone of the band

  • sentenceoptional
    string

    statement: the sentence, {0}, {1} … stand for the metrics in order

  • noteoptional
    string

    A 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
liveEnglisch LiveDeutsch Live
updatedEnglisch Updated every few secondsDeutsch Alle paar Sekunden aktualisiert
ofEnglisch of {g}Deutsch von {g}
reachedEnglisch Goal reachedDeutsch 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
components/ui/metrics-band.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert