Particular.Tech
Data displayui

Metric card

KPI value with change versus a previous period and a sparkline.

Requests
2.05M
+12.4%vs last week
p95 latency
96 ms
-8%vs last week
Error rate
0.42%
+31%vs last week

Installation

npx shadcn@latest add @particular/metric-card
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0
Installs these registry items

Source

ui/metric-card.tsx
import * as React from "react"
import { ArrowDownRightIcon, ArrowUpRightIcon, MinusIcon } from "lucide-react"
import { cn } from "cn"

import { Card } from "@/components/ui/card"

/** SVG path for a sparkline that fills `width`×`height` with 1px vertical padding. */
function sparklinePath(values: number[], width: number, height: number): string {
  if (values.length < 2) return ""
  const min = Math.min(...values)
  const max = Math.max(...values)
  const span = max - min || 1
  const step = width / (values.length - 1)
  return values
    .map((value, i) => {
      const x = i * step
      const y = 1 + (height - 2) * (1 - (value - min) / span)
      return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`
    })
    .join(" ")
}

function Sparkline({ values, className }: { values: number[]; className?: string }) {
  const width = 120
  const height = 32
  const line = sparklinePath(values, width, height)
  if (!line) return null
  return (
    <svg
      aria-hidden
      viewBox={`0 0 ${width} ${height}`}
      preserveAspectRatio="none"
      className={cn("h-8 w-full overflow-visible", className)}
    >
      <path d={`${line} L${width},${height} L0,${height} Z`} className="fill-current opacity-10" />
      <path
        d={line}
        className="fill-none stroke-current"
        strokeWidth={1.5}
        vectorEffect="non-scaling-stroke"
        strokeLinejoin="round"
      />
    </svg>
  )
}

const percentFormat = new Intl.NumberFormat("en-US", {
  style: "percent",
  maximumFractionDigits: 1,
  signDisplay: "exceptZero",
})

function MetricCard({
  label,
  value,
  delta,
  deltaLabel,
  invertDelta = false,
  trend,
  icon,
  className,
  ...props
}: Omit<React.ComponentProps<typeof Card>, "children"> & {
  label: React.ReactNode
  value: React.ReactNode
  /** Relative change, e.g. `0.124` for +12.4%. */
  delta?: number
  deltaLabel?: React.ReactNode
  /** Set when lower is better (error rate, latency): a decrease renders as positive. */
  invertDelta?: boolean
  trend?: number[]
  icon?: React.ReactNode
}) {
  const direction = delta === undefined || delta === 0 ? "flat" : delta > 0 ? "up" : "down"
  const good = direction === "flat" ? null : (direction === "up") !== invertDelta
  const DeltaIcon = direction === "up" ? ArrowUpRightIcon : direction === "down" ? ArrowDownRightIcon : MinusIcon

  return (
    <Card data-slot="metric-card" className={cn("gap-3 px-4", className)} {...props}>
      <div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
        <span>{label}</span>
        {icon && <span className="[&_svg]:size-4">{icon}</span>}
      </div>
      <div className="font-heading text-2xl font-semibold tracking-tight tabular-nums">{value}</div>
      {delta !== undefined && (
        <div className="flex items-center gap-1.5 text-xs">
          <span
            className={cn(
              "inline-flex items-center gap-0.5 font-medium tabular-nums",
              good === null && "text-muted-foreground",
              good === true && "text-success",
              good === false && "text-destructive"
            )}
          >
            <DeltaIcon className="size-3.5" aria-hidden />
            {percentFormat.format(delta)}
          </span>
          {deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
        </div>
      )}
      {trend && (
        <Sparkline
          values={trend}
          className={cn(good === false ? "text-destructive" : good === true ? "text-success" : "text-brand")}
        />
      )}
    </Card>
  )
}

export { MetricCard, Sparkline, sparklinePath }