Metric trend
A dashboard's lead chart: metrics as tabs, the selected one's trend below. Each can compare with an earlier period.
Installation
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- As the chart a dashboard leads with: two to four numbers a team watches, each with its change, and the history of the selected one, in the room of one chart.
- For "how is this quarter going against last quarter, week by week": the trend draws this period's line over the comparison period's, and the tooltip names both dates.
Usage
import { useId } from "react"
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { MetricTrendTabs } from "@/components/wisty/metric-trend"export function SalesPerformance() {
const titleId = useId()
return (
<Card>
<CardHeader>
<CardTitle id={titleId}>Sales performance</CardTitle>
<CardDescription>This quarter against last, by week</CardDescription>
</CardHeader>
<MetricTrendTabs
aria-labelledby={titleId}
period="This quarter"
comparison="Last quarter"
currency="USD"
metrics={[
{
id: "closed-won",
label: "Closed won",
value: 420_000,
previous: 500_000,
points: [
{ label: "Week of Jul 7", value: 120_000 },
{ label: "Week of Jul 14", value: 420_000 },
],
},
{
id: "open-pipeline",
label: "Open pipeline",
value: 1_800_000,
previous: 1_500_000,
points: [
{ label: "Week of Jul 7", value: 1_650_000 },
{ label: "Week of Jul 14", value: 1_800_000 },
],
},
]}
/>
</Card>
)
}The page writes the card and its header; the block is the tabs and the chart. Put it straight in the Card, after the CardHeader and not in CardContent, so the tabs run to the card's edges. A CardAction in the header holds a control of the page's own, like a period picker.
The tab shows value, the period's summary as the app knows it. The block never adds up the points. Each point measures what value measures, at that time: a snapshot (open pipeline that week) or a running total (closed won to date). previous on a point draws the comparison period as a dashed line, and previousLabel names its date in the tooltip. A point with value: null leaves a gap, as the Support health example shows for the days still to come.
Examples
Support health
Open cases and first response, where a fall is good (`isIncreasePositive: false`), ten days in, so `null` points end the lines, and CSAT has no `previous`.
API reference
MetricTrendTabs
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | Names the tabs when no title on the page does. | |
aria-labelledby | string | The id of the card title that names the tabs. | |
metrics* | MetricTrendMetric[] | Two to four metrics, in tab order. | |
value | string | The selected metric's id, controlled. | |
defaultValue | string | The metric to start on when uncontrolled. Default the first. | |
onValueChange | (id: string) => void | Called when a tab is chosen. | |
period | string | "This period" | This period's name, in the tooltip and legend. Default "This period". |
comparison | string | "Previous period" | The comparison period's name. Default "Previous period". |
currency | string | An ISO 4217 code for metrics with no format: their values read as amounts in it. Without one they read as plain numbers. | |
locale | string | "en-US" | A BCP 47 locale for numbers. Default "en-US". |
Also takes every <div> (opens in a new tab) prop.
MetricTrendPointtype
| Member | Type | Default | Description |
|---|---|---|---|
label* | string | The bucket in this period, like "Week of Sep 8". | |
value* | number | null | null leaves a gap, as for a week still to come. | |
previous | number | null | The comparable bucket's value in the comparison period. | |
previousLabel | string | That bucket's label, like "Week of Jun 9", shown in the tooltip. |
MetricTrendMetrictype
One metric, as a tab and its chart.
| Member | Type | Default | Description |
|---|---|---|---|
color | 1 | 2 | 3 | 4 | 5 | 6 | Which chart color the line takes, --chart-1 to --chart-6. Default 1. | |
points* | MetricTrendPoint[] | The trend, oldest first. Each point measures what value measures, at that time. | |
id* | string | ||
label* | string | What's measured, in sentence case: "Open pipeline". | |
value* | number | null | The current value. null shows "No data" and no change. | |
previous | number | null | The comparison period's value. Shows the change against it. | |
format | Intl.NumberFormatOptions | How the value reads, as Intl.NumberFormat options. Default an amount in the block's currency, or a plain number without one. | |
isIncreasePositive | boolean | Whether a rise is good. Default true. Pass false for open cases, sales cycle, handle time, churn. |
Keyboard interactions
| Key | Action |
|---|---|
| Arrow Left, Arrow Right | On the tabs: moves between metrics. In the chart: moves the tooltip between points. |
| Home, End | On the tabs: the first or last metric. |
| Enter, Space | Selects the focused tab. |
Tab goes from the selected tab straight into the chart. In Radix projects the arrow keys select each tab they reach. In Base UI projects they only move focus, and Enter or Space selects.
Accessibility
aria-labelledbynames the tab list: point it at the card's title, or passaria-labelwhen there is none. Each tab's name is its metric's whole text ("Closed won $3.41M −4.2% vs Last quarter"), so keep labels short.- Direction is in the signed text. Whether a change is good shows in color only.
- The chart's points are reachable from the keyboard but announce nothing. The same points sit in a visually hidden table, one row per bucket with the comparison's value and date, so the trend is readable without the chart.
Motion
- The chart draws at once, with no entrance animation, so switching tabs doesn't shimmer.
Responsive behavior
- The tabs share the card's width, at least 9rem each, and the list scrolls sideways when they don't fit. A long label truncates; the value doesn't shrink.
- The chart is 16rem tall at any width. The axis thins its date labels as the card narrows.
Performance
- Only the selected metric's chart is mounted. Switching tabs mounts the next one, and the chart library loads with the block. Keep to one such card per page.
Rules
- Pass numbers to
value,previousand every point, never formatted strings, andnullfor a point you don't have yet, never 0. - For a quarter-to-date total, pass running totals as points, not weekly amounts.
- Pass a percent as a fraction (0.318) with a percent
format, so its change reads in points. - Set
isIncreasePositive: falseon every metric where a fall is good, like open cases, handle time or churn. Never flip the sign to get a color. - Give every point a
previousLabelwhen it has aprevious, so the tooltip names both dates. - Pass amounts in one currency and set
currencyto match, or give the metric its own currencyformat. Without either, a metric reads as a plain number. Mixed currencies are unavailable (value: null), not added up. - Name the tabs with
aria-labelledby(the card title's id) oraria-label. - Put the block directly in the
Card, never inCardContent. - Never pass
asChildorrender.