137 / Charts
Metric Card
A KPI card whose chart you can run along to read any day. Let go and the headline goes back to the total.
Gross volume
$120,071
Refunds
498
"use client"
import * as React from "react"
import {
MetricCard,
type MetricPoint,
} from "mischief-ui/metric-card"
const END = Date.UTC(2026, 10, 1)
const DAY = 86_400_000
const periods = [
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
]
function series(days: number, base: number, seed: number): MetricPoint[] {
let state = seed
const random = () => {
state = (state * 16807) % 2147483647
return state / 2147483647
}
return Array.from({ length: days }, (_, index) => {
const time = END - (days - 1 - index) * DAY
const weekday = new Date(time).getUTCDay()
const weekend = weekday === 0 || weekday === 6
const growth = 1 + (index / days) * 0.18
const value = base * growth * (weekend ? 0.62 : 1) * (0.85 + random() * 0.3)
return {
date: new Date(time).toISOString().slice(0, 10),
value: Math.round(value),
}
})
}
const revenue = Object.fromEntries(
periods.map(({ value }) => {
const days = Number.parseInt(value, 10)
const current = series(days, 4200, days)
const previous = series(days, 3900, days + 11).reduce(
(sum, point) => sum + point.value,
0
)
return [value, { current, previous }]
})
)
const refunds = series(30, 18, 5)
const refundsBefore = series(30, 21, 9).reduce(
(sum, point) => sum + point.value,
0
)
export function MetricCardDemo() {
const [period, setPeriod] = React.useState("30d")
const selected = revenue[period] ?? revenue["30d"]
return (
<div className="grid w-full max-w-[44rem] gap-4 sm:grid-cols-[3fr_2fr]">
<MetricCard
data={selected?.current ?? []}
format={{
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}}
label="Gross volume"
locale="en-US"
onPeriodChange={setPeriod}
period={period}
periods={periods}
previous={selected?.previous}
/>
<MetricCard
chartHeight={72}
comparisonLabel="vs previous 30 days"
data={refunds}
goodDirection="down"
label="Refunds"
locale="en-US"
previous={refundsBefore}
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/metric-cardimport { MetricCard } from "mischief-ui/metric-card"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type MetricPoint = { /** A calendar day as YYYY-MM-DD, or any ISO date. Shown in UTC. */ date: string value: number} export type MetricPeriod = { value: string; label: string } Usage
export function Volume() {
const [period, setPeriod] = useState("30d")
const { days, previousTotal } = useVolume(period)
return (
<MetricCard
label="Gross volume"
data={days}
previous={previousTotal}
format={{ style: "currency", currency: "USD" }}
periods={[
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "90d", label: "90d" },
]}
period={period}
onPeriodChange={setPeriod}
/>
)
}The headline and the change
The headline is the sum of the days unless you pass value, which is what you want for a rate or an average. previous is the same measure for the period before. The change is shown as a signed percentage with an arrow, and takes the destructive colour only when it moves against goodDirection.
<MetricCard
label="Conversion"
data={days}
value={0.034}
previous={0.031}
format={{ style: "percent", maximumFractionDigits: 1 }}
/>Reading a day
Moving a pointer over the chart, or dragging a finger along it, puts a line on that day and swaps the headline for that day's value, with its date where the change was. Leaving the chart puts the total back. Vertical swipes still scroll the page. With the keyboard the chart is one tab stop: the arrows step by a day, Page Up and Page Down by a week, Home and End to either end, and Escape lets go.
Periods
periods draws a segmented control. The card does not fetch anything, so swap data and previous yourself when onPeriodChange fires. It works controlled with period or uncontrolled with defaultPeriod.
Motion
When the headline changes, each digit rolls to its new value. While you scrub, the digits change at once so the number keeps up with your hand. With reduced motion every change is immediate.
API
labelstringWhat is measured. Names the card.dataMetricPoint[]One point per day of the current period, oldest first.date, value (MetricPoint)string, numberA day as YYYY-MM-DD, shown in UTC, and its value.valuenumberThe headline. Defaults to the sum of the data.previousnumberThe same measure for the period before. Omit it and no change is shown.goodDirection"up" | "down"Which way is good news. Defaults to up.comparisonLabelstringText after the change. Defaults to vs previous period.formatIntl.NumberFormatOptionsHow the headline and every day are written.dateFormatIntl.DateTimeFormatOptionsHow the day being read is written. Defaults to Tue, Oct 20.localestringThe locale for numbers and dates.periods (MetricPeriod){ value: string; label: string }[]Draws a segmented control with these choices.periodstringThe selected period, when controlled.defaultPeriodstringThe first selected period, when uncontrolled.onPeriodChange(period: string) => voidRuns when a period is chosen.colorstringAny CSS colour. Defaults to var(--chart-1), then var(--primary).chartHeightnumberHeight of the chart in pixels. Defaults to 96.data-state"idle" | "scrubbing"Written to the root for styling.classNamestringClasses for placement and appearance....divPropsHTMLAttributesNative div attributes.Accessibility
The card is a group named by its label. The headline's accessible text is always the total, so a screen reader is never read a blur of rolling digits. The chart is a slider whose value text names the day and its value, such as Tue, Oct 20: $4,255. The change pairs its colour with an arrow and a sign and is read as Up 6.7% rather than as a symbol. The period control is a radio group with arrow-key movement and a single tab stop.