Zum Inhalt springen
UI Kit
Accent

Kennzahl

Eine Zahl, die zählt, mit Veränderung, Sparkline oder Ziel.

Eine von 4 Components in Fortschritt und Zustände

3 Variants

Variants

Reihe

Vier Kennzahlen in einem Rahmen mit feinen Linien.

Mit Sparkline

Verlinkte Karten mit Icon und einer Linie über zwölf Wochen.

Hervorgehoben und Ziele

Eine große Akzentkarte mit Sparkline, zwei Karten mit Zielen.

Details

Was Kennzahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/stat-card.tsx
Bringt mit
utilskiticonnumber-ticker
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 4 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle StatCardProps aus components/ui/stat-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • labelPflicht
    React.ReactNode
  • valuePflicht
    React.ReactNode

    The formatted number, e.g. "$48,210" or "48.210 €"

  • deltaoptional
    StatDelta
  • iconoptional
    IconName
  • descriptionoptional
    React.ReactNode
  • sparklineoptional
    number[]

    Values for a small chart under the number

  • goaloptional
    { value: number; max: number; label: React.ReactNode }

    Progress towards a goal

  • hrefoptional
    string

    Makes the whole card a link

  • variantoptional
    StatCardVariant

    defaultplainaccent

    default: card. plain: no frame, for StatGrid. accent: filled with the accent color.

  • countoptional
    boolean

    Count the number up when the card scrolls into view. Default true, works for values with one number.

  • sizeoptional
    'md' | 'lg'

Eingebaute Texte

Diese Texte schreibt Kennzahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 4 Texte anzeigen
upEnglisch upDeutsch gestiegen
downEnglisch downDeutsch gesunken
flatEnglisch unchangedDeutsch unverändert
goalEnglisch of goalDeutsch vom Ziel

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 8 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 238 Zeilen
components/ui/stat-card.tsx
'use client';

import { useId, useRef } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';

export type StatTrend = 'up' | 'down' | 'flat';

/** Entrance only for elements that were below the fold, so nothing above it blinks on load */
function useEntrance<T extends Element>() {
  const [ref, inView] = useInView<T>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  return [ref, inView, inView && waited.current] as const;
}

/**
 * A formatted value like "$48,210", "48.210 €" or "3,8 %" with exactly one number in it
 * counts up with the number ticker, the text around it stays. Anything else shows as it is.
 */
function CountedValue({ value }: { value: React.ReactNode }) {
  const locale = useLocale();
  if (typeof value !== 'string') return <>{value}</>;
  const runs = value.match(/\d[\d.,]*\d|\d/g);
  if (!runs || runs.length !== 1) return <>{value}</>;
  const run = runs[0];
  const de = locale === 'de';
  const plain = de ? run.replace(/\./g, '').replace(',', '.') : run.replace(/,/g, '');
  const n = Number(plain);
  const decimals = plain.includes('.') ? plain.length - plain.indexOf('.') - 1 : 0;
  const fmt = new Intl.NumberFormat(de ? 'de-DE' : 'en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
  // Only when the ticker writes the number exactly as given
  if (!Number.isFinite(n) || fmt.format(n) !== run) return <>{value}</>;
  const at = value.indexOf(run);
  return (
    <>
      {value.slice(0, at)}
      <NumberTicker value={n} format={{ minimumFractionDigits: decimals, maximumFractionDigits: decimals }} duration={1600} />
      {value.slice(at + run.length)}
    </>
  );
}
export type StatCardVariant = 'default' | 'plain' | 'accent';

export interface StatDelta {
  /** Formatted change, e.g. "+12.4%" or "−3" */
  value: string;
  trend: StatTrend;
  /** Is this change good news? Defaults to up = good, down = bad. Pass false for e.g. rising costs. */
  positive?: boolean;
  /** Small text after the delta, e.g. "vs last month" */
  note?: string;
}

export interface StatCardProps {
  label: React.ReactNode;
  /** The formatted number, e.g. "$48,210" or "48.210 €" */
  value: React.ReactNode;
  delta?: StatDelta;
  icon?: IconName;
  description?: React.ReactNode;
  /** Values for a small chart under the number */
  sparkline?: number[];
  /** Progress towards a goal */
  goal?: { value: number; max: number; label: React.ReactNode };
  /** Makes the whole card a link */
  href?: string;
  /** default: card. plain: no frame, for StatGrid. accent: filled with the accent color. */
  variant?: StatCardVariant;
  /** Count the number up when the card scrolls into view. Default true, works for values with one number. */
  count?: boolean;
  size?: 'md' | 'lg';
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: { up: 'up', down: 'down', flat: 'unchanged', goal: 'of goal' },
  de: { up: 'gestiegen', down: 'gesunken', flat: 'unverändert', goal: 'vom Ziel' },
};

/** The line draws itself from the start when it scrolls into view, the area fades in under it, the end point pops */
function Spark({ values, accent, tall }: { values: number[]; accent: boolean; tall?: boolean }) {
  const id = useId().replace(/:/g, '');
  const [ref, inView, entering] = useEntrance<SVGSVGElement>();
  const w = 240;
  const h = 56;
  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min || 1;
  const pts = values.map((v, i) => [round((i / (values.length - 1)) * w), round(h - 4 - ((v - min) / span) * (h - 10))] as const);
  // Smooth curve through the points (monotone enough for small series)
  const d = pts.reduce((acc, [x, y], i) => {
    if (i === 0) return `M${x} ${y}`;
    const [px, py] = pts[i - 1];
    const cx = round((px + x) / 2);
    return `${acc} C${cx} ${py} ${cx} ${y} ${x} ${y}`;
  }, '');
  const stroke = accent ? 'var(--primary-foreground)' : 'var(--primary)';
  const [lx, ly] = pts[pts.length - 1];
  return (
    <div className={cn('relative w-full', tall ? 'h-24 sm:h-32' : 'h-14')} aria-hidden>
    <svg ref={ref} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
      <defs>
        <linearGradient id={`sc-${id}`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor={stroke} stopOpacity={accent ? 0.28 : 0.2} />
          <stop offset="1" stopColor={stroke} stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d={`${d} L${w} ${h} L0 ${h} Z`} fill={`url(#sc-${id})`} className="transition-[opacity,clip-path] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ opacity: inView ? 1 : 0, clipPath: `inset(0 ${inView ? 0 : 100}% 0 0)` }} />
      <path
        d={d}
        fill="none"
        stroke={stroke}
        strokeWidth="2"
        strokeLinecap="round"
        vectorEffect="non-scaling-stroke"
        pathLength={1}
        strokeDasharray="1 1"
        strokeDashoffset={inView ? 0 : 1}
        className="transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
      />
    </svg>
      <span
        className={cn('absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-4 transition-[opacity,scale] duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', entering && 'delay-[1200ms]', accent ? 'bg-primary-foreground ring-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)]' : 'bg-primary ring-[color-mix(in_oklab,var(--primary)_16%,transparent)]', inView ? 'scale-100 opacity-100' : 'scale-0 opacity-0')}
        style={{ left: `${round((lx / w) * 100)}%`, top: `${round((ly / h) * 100)}%` }}
      />
    </div>
  );
}

function Delta({ delta, accent, t }: { delta: StatDelta; accent: boolean; t: (typeof TEXTS)['en'] }) {
  const good = delta.positive ?? delta.trend === 'up';
  const tone = delta.trend === 'flat' ? 'neutral' : good ? 'good' : 'bad';
  return (
    <span className="inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[13px]">
      <span
        className={cn(
          'inline-flex h-6 items-center gap-0.5 rounded-md px-1.5 font-medium tabular-nums',
          accent
            ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_16%,transparent)] text-primary-foreground'
            : tone === 'good'
              ? 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground'
              : tone === 'bad'
                ? 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]'
                : 'bg-muted text-muted-foreground',
        )}
      >
        <Icon name={delta.trend === 'up' ? 'arrow-up-right' : delta.trend === 'down' ? 'arrow-down-right' : 'arrow-right'} size={13} strokeWidth={2} />
        {delta.value}
        <span className="sr-only">, {t[delta.trend]}</span>
      </span>
      {delta.note && <span className={accent ? 'text-[color-mix(in_oklab,var(--primary-foreground)_72%,transparent)]' : 'text-muted-foreground'}>{delta.note}</span>}
    </span>
  );
}

/** A number that matters: label, value, change, and optionally a sparkline or a goal. */
export function StatCard({ label, value, delta, icon, description, sparkline, goal, href, variant = 'default', size = 'md', count = true, labels, className }: StatCardProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const accent = variant === 'accent';
  const share = goal ? Math.min(goal.value / (goal.max || 1), 1) : 0;
  const Tag = href ? 'a' : 'div';
  const [ref, inView, entering] = useEntrance<HTMLDivElement>();
  return (
    <Tag
      href={href}
      ref={ref as React.Ref<HTMLDivElement & HTMLAnchorElement>}
      className={cn(
        'group/stat relative flex min-w-0 flex-col',
        variant === 'default' && 'kit-card p-5 sm:p-6',
        variant === 'plain' && 'bg-card p-5 sm:p-6',
        accent && 'kit-shine overflow-hidden rounded-[calc(var(--radius)+4px)] bg-primary p-5 text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.16),var(--shadow-md)] sm:p-6',
        href && 'transition-[background-color,box-shadow,translate] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:hover:translate-y-0',
        href && variant === 'plain' && 'hover:bg-surface',
        className,
      )}
    >
      <div className="flex items-start justify-between gap-3">
        <p className={cn('text-[13px] font-medium', accent ? 'text-[color-mix(in_oklab,var(--primary-foreground)_78%,transparent)]' : 'text-muted-foreground')}>{label}</p>
        {icon && (
          <span className={cn('grid size-8 shrink-0 place-items-center rounded-lg transition-[scale,rotate] duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover/stat:-rotate-6 group-hover/stat:scale-110 motion-reduce:transition-none', accent ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_14%,transparent)]' : 'border border-border bg-surface text-muted-foreground')}>
            <Icon name={icon} size={16} />
          </span>
        )}
        {href && !icon && <Icon name="arrow-up-right" size={16} className="text-subtle-foreground transition-transform duration-200 group-hover/stat:-translate-y-0.5 group-hover/stat:translate-x-0.5 motion-reduce:transition-none" />}
      </div>
      <p className={cn('mt-2 font-semibold tabular-nums tracking-[-0.03em]', size === 'lg' ? 'text-[2.5rem] leading-[1.1] sm:text-[3rem]' : 'text-[1.75rem] leading-tight')}>{count ? <CountedValue value={value} /> : value}</p>
      {delta && (
        <div className={cn('mt-2 transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-1.5 opacity-0')} style={{ transitionDelay: entering ? '700ms' : undefined }}>
          <Delta delta={delta} accent={accent} t={t} />
        </div>
      )}
      {description && <p className={cn('mt-3 text-pretty text-sm', accent ? 'text-[color-mix(in_oklab,var(--primary-foreground)_78%,transparent)]' : 'text-muted-foreground')}>{description}</p>}
      {sparkline && sparkline.length > 1 && (
        <div className="mt-auto pt-5">
          <Spark values={sparkline} accent={accent} tall={size === 'lg'} />
        </div>
      )}
      {goal && (
        <div className="mt-auto pt-5">
          <div className={cn('h-1.5 overflow-hidden rounded-full', accent ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]')}>
            <div
              data-clip-ok
              className={cn('h-full w-full rounded-full transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none', accent ? 'bg-primary-foreground' : 'bg-primary')}
              style={{ transform: `translateX(${inView ? round((share - 1) * 100) : -100}%)`, transitionDelay: entering ? '300ms' : undefined }}
            />
          </div>
          <p className={cn('mt-2 flex justify-between gap-3 text-[12px]', accent ? 'text-[color-mix(in_oklab,var(--primary-foreground)_75%,transparent)]' : 'text-muted-foreground')}>
            <span>{goal.label}</span>
            <span className="tabular-nums">
              {new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'percent' }).format(share)} {t.goal}
            </span>
          </p>
        </div>
      )}
    </Tag>
  );
}

/** Stats side by side with hairlines between them. Pass plain StatCards. */
export function StatGrid({ columns = 4, className, children }: { columns?: 2 | 3 | 4; className?: string; children: React.ReactNode }) {
  return (
    <div
      className={cn(
        'grid gap-px overflow-hidden rounded-[calc(var(--radius)+4px)] border border-border bg-border shadow-[var(--shadow-sm)]',
        columns === 2 && 'sm:grid-cols-2',
        columns === 3 && 'sm:grid-cols-3',
        columns === 4 && 'grid-cols-2 lg:grid-cols-4',
        className,
      )}
    >
      {children}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Kennzahl?

Mit aiw add @ai-website/stat-card. Der Befehl legt die Datei components/ui/stat-card.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und number-ticker. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Kennzahl?

3 Varianten, Reihe, Mit Sparkline und Hervorgehoben und Ziele. Du wählst sie mit der Prop variant, zum Beispiel variant="accent".

Kann ich die Texte von Kennzahl übersetzen?

Ja. Kennzahl bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „gestiegen“, „gesunken“ und „unverändert“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kennzahl einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kennzahl mit aiw_components und installieren sie mit aiw_add @ai-website/stat-card. Nenn im Prompt die Variante, etwa variant="plain", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert