Stat card
One number that matters, with change, sparkline or goal.
One of 4 components in Progress and states
Variants
Details
What Stat card brings along, read from the registry and the source.
- Category
- Progress and states
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/stat-card.tsx
- Brings along
- utilskiticonnumber-ticker
- 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 StatCardProps interface from components/ui/stat-card.tsx, with the comments from the code.
labelrequiredReact.ReactNodevaluerequiredReact.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'
Built-in texts
Stat card writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| up | English up | German gestiegen |
| down | English down | German gesunken |
| flat | English unchanged | German unverändert |
| goal | English of goal | German vom Ziel |
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 238 lines
'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>
);
}Frequent questions
How do I install Stat card?
With aiw add @ai-website/stat-card. The command puts components/ui/stat-card.tsx into your project, the code is yours from then on. It brings along icon and number-ticker. It needs no further npm packages.
Which variants does Stat card have?
3 variants, Row, With sparkline and Feature and goals. You pick one with the variant prop, for example variant="accent".
Can I translate the texts of Stat card?
Yes. Stat card carries 4 texts of its own in English and German, such as “up”, “down” and “unchanged”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Stat card?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Stat card with aiw_components and install it with aiw_add @ai-website/stat-card. Name the variant in your prompt, for example variant="plain", and the assistant writes your content straight into the props.