Skip to content
UI Kit
Accent color

Stat card

One number that matters, with change, sparkline or goal.

One of 4 components in Progress and states

3 variants

Variants

Row

Four stats in one frame with hairlines.

With sparkline

Linked cards with an icon and a twelve week line.

Feature and goals

One large accent card with a sparkline, two cards with goals.

Details

What Stat card brings along, read from the registry and the source.

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

Props

The StatCardProps interface from components/ui/stat-card.tsx, with the comments from the code.

  • labelrequired
    React.ReactNode
  • valuerequired
    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'

Built-in texts

Stat card writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
upEnglish upGerman gestiegen
downEnglish downGerman gesunken
flatEnglish unchangedGerman unverändert
goalEnglish of goalGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard