Kennzahl
Eine Zahl, die zählt, mit Veränderung, Sparkline oder Ziel.
Eine von 4 Components in Fortschritt und Zustände
Variants
Details
Was Kennzahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Fortschritt und Zustände
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StatCardProps aus components/ui/stat-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
labelPflichtReact.ReactNodevaluePflichtReact.ReactNodeThe formatted number, e.g. "$48,210" or "48.210 €"
deltaoptionalStatDeltaiconoptionalIconNamedescriptionoptionalReact.ReactNodesparklineoptionalnumber[]Values for a small chart under the number
goaloptional{ value: number; max: number; label: React.ReactNode }Progress towards a goal
hrefoptionalstringMakes the whole card a link
variantoptionalStatCardVariantdefaultplainaccent
default: card. plain: no frame, for StatGrid. accent: filled with the accent color.
countoptionalbooleanCount 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| up | Englisch up | Deutsch gestiegen |
| down | Englisch down | Deutsch gesunken |
| flat | Englisch unchanged | Deutsch unverändert |
| goal | Englisch of goal | Deutsch 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
'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.