Skip to content

Metric

A KPI tile with trend direction, invertible "good" semantics and a decorative sparkline.

DatastarterFeaturedmetrickpidashboardstatsparkline

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { CreditCard, Timer, Users, Zap } from 'lucide-react'
import { Metric } from '@/components/ui/metric'
import { DemoGrid, DemoStage } from './_kit'

export default function MetricDemo() {
  return (
    <DemoStage>
      <DemoGrid columns={2}>
        <Metric
          label="Monthly recurring revenue"
          value="$184,200"
          delta={12.4}
          deltaLabel="vs last month"
          icon={<CreditCard className="size-4" />}
          sparkline={[12, 18, 15, 22, 26, 24, 31, 34]}
        />
        <Metric
          label="Active seats"
          value="1,284"
          delta={4.1}
          deltaLabel="vs last month"
          icon={<Users className="size-4" />}
          sparkline={[40, 42, 41, 45, 47, 49, 52, 54]}
        />
        <Metric
          label="p95 latency"
          value="182 ms"
          delta={-8.3}
          deltaLabel="vs last week"
          invertTrend
          icon={<Timer className="size-4" />}
          sparkline={[30, 28, 31, 26, 24, 22, 20, 18]}
        />
        <Metric
          label="Error rate"
          value="0.42%"
          delta={2.7}
          deltaLabel="vs last week"
          invertTrend
          icon={<Zap className="size-4" />}
          sparkline={[8, 7, 9, 11, 10, 12, 13, 15]}
        />
      </DemoGrid>

      <DemoGrid columns={3}>
        <Metric appearance="plain" label="Uptime" value="99.98%" />
        <Metric appearance="plain" label="Open incidents" value="0" delta={0} />
        <Metric appearance="plain" label="Deploys this week" value="47" delta={22.1} />
      </DemoGrid>
    </DemoStage>
  )
}

components/demos/metric.tsx

import { CreditCard, Timer, Users, Zap } from 'lucide-react'
import { Metric } from '@/components/ui/metric'
import { DemoGrid, DemoStage } from './_kit'

export default function MetricDemo() {
  return (
    <DemoStage>
      <DemoGrid columns={2}>
        <Metric
          label="Monthly recurring revenue"
          value="$184,200"
          delta={12.4}
          deltaLabel="vs last month"
          icon={<CreditCard className="size-4" />}
          sparkline={[12, 18, 15, 22, 26, 24, 31, 34]}
        />
        <Metric
          label="Active seats"
          value="1,284"
          delta={4.1}
          deltaLabel="vs last month"
          icon={<Users className="size-4" />}
          sparkline={[40, 42, 41, 45, 47, 49, 52, 54]}
        />
        <Metric
          label="p95 latency"
          value="182 ms"
          delta={-8.3}
          deltaLabel="vs last week"
          invertTrend
          icon={<Timer className="size-4" />}
          sparkline={[30, 28, 31, 26, 24, 22, 20, 18]}
        />
        <Metric
          label="Error rate"
          value="0.42%"
          delta={2.7}
          deltaLabel="vs last week"
          invertTrend
          icon={<Zap className="size-4" />}
          sparkline={[8, 7, 9, 11, 10, 12, 13, 15]}
        />
      </DemoGrid>

      <DemoGrid columns={3}>
        <Metric appearance="plain" label="Uptime" value="99.98%" />
        <Metric appearance="plain" label="Open incidents" value="0" delta={0} />
        <Metric appearance="plain" label="Deploys this week" value="47" delta={22.1} />
      </DemoGrid>
    </DemoStage>
  )
}

components/ui/metric.tsx

import type { ReactNode } from 'react'
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Metric
 *
 * A single number with optional trend. The delta carries an arrow *and* a sign
 * *and* a colour, so the direction survives a greyscale print or a colour
 * vision deficiency.
 *
 * `<dl>` semantics pair the label with the value; the sparkline is decorative
 * and hidden from assistive tech, since the number beside it is the content.
 */
export interface MetricProps {
  label: string
  value: string
  /** Percentage change. Positive is not automatically "good" — see `invertTrend`. */
  delta?: number
  deltaLabel?: string
  /** For metrics where down is good, e.g. churn or latency. */
  invertTrend?: boolean
  icon?: ReactNode
  /** 0–1 values rendered as a decorative sparkline. */
  sparkline?: number[]
  appearance?: 'plain' | 'card'
  className?: string
}

function Sparkline({ points }: { points: number[] }) {
  if (points.length < 2) return null
  const max = Math.max(...points)
  const min = Math.min(...points)
  const range = max - min || 1
  const path = points
    .map((point, index) => {
      const x = (index / (points.length - 1)) * 100
      const y = 28 - ((point - min) / range) * 26 - 1
      return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`
    })
    .join(' ')

  return (
    <svg viewBox="0 0 100 28" preserveAspectRatio="none" className="h-7 w-full" aria-hidden="true">
      <path
        d={path}
        fill="none"
        stroke="var(--color-accent)"
        strokeWidth="1.5"
        vectorEffect="non-scaling-stroke"
      />
    </svg>
  )
}

export function Metric({
  label,
  value,
  delta,
  deltaLabel,
  invertTrend = false,
  icon,
  sparkline,
  appearance = 'card',
  className,
}: MetricProps) {
  const direction = delta === undefined ? 'flat' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'
  const good = direction === 'flat' ? null : invertTrend ? direction === 'down' : direction === 'up'
  const TrendIcon =
    direction === 'up' ? ArrowUpRight : direction === 'down' ? ArrowDownRight : Minus

  return (
    <dl
      className={cn(
        'flex min-w-0 flex-col gap-1',
        appearance === 'card' && 'rounded-lg border border-line bg-surface p-card',
        className,
      )}
    >
      <div className="flex items-center justify-between gap-2">
        <dt className="label-caps truncate text-ink-subtle">{label}</dt>
        {icon ? <span className="shrink-0 text-ink-subtle">{icon}</span> : null}
      </div>
      <dd className="display-type text-2xl leading-none font-semibold text-ink-strong tabular-nums">
        {value}
      </dd>
      {delta !== undefined ? (
        <dd
          className={cn(
            'flex items-center gap-1 text-xs font-medium',
            good === null ? 'text-ink-muted' : good ? 'text-success' : 'text-danger',
          )}
        >
          <TrendIcon className="size-3.5 shrink-0" aria-hidden="true" />
          <span>
            {delta > 0 ? '+' : ''}
            {delta}%
          </span>
          {deltaLabel ? <span className="text-ink-subtle">{deltaLabel}</span> : null}
        </dd>
      ) : null}
      {sparkline ? (
        <dd className="mt-1">
          <Sparkline points={sparkline} />
        </dd>
      ) : null}
    </dl>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For a single number that matters. `invertTrend` exists because a rise is not automatically good — latency and error rate going up is a regression, and the component should not colour it green.

  • The delta carries an arrow, a sign and a colour, so direction survives greyscale.
  • Sparklines are decorative and hidden from assistive tech; the number beside them is the content.
  • Keep labels short — a metric label that wraps to two lines is a description, not a label.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Card and plain
  • With delta
  • Inverted trend — for churn and latency
  • With sparkline

Accessibility

Semantics
Rendered as a description list so the label and value are programmatically paired.
Sparkline
The SVG is `aria-hidden`; it adds shape, not information.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.