Skip to content

Metric trend

A dashboard's lead chart: metrics as tabs, the selected one's trend below. Each can compare with an earlier period.

Sales performance
Q3 FY2027 against Q2, by week
Open pipeline, This quarter and Last quarter
This quarterOpen pipelineLast quarter
Week of Jul 7$9.8M$9.4M, Week of Apr 7
Week of Jul 14$10.1M$9.6M, Week of Apr 14
Week of Jul 21$10.4M$9.5M, Week of Apr 21
Week of Jul 28$10.2M$9.9M, Week of Apr 28
Week of Aug 4$10.9M$10.2M, Week of May 5
Week of Aug 11$11.3M$10M, Week of May 12
Week of Aug 18$11.1M$10.4M, Week of May 19
Week of Aug 25$11.6M$10.6M, Week of May 26
Week of Sep 1$11.9M$10.5M, Week of Jun 2
Week of Sep 8$12.2M$10.8M, Week of Jun 9
Week of Sep 15$12M$11M, Week of Jun 16
Week of Sep 22$12.3M$10.9M, Week of Jun 23
Week of Sep 29$12.5M$11M, Week of Jun 30

Installation

pnpm dlx shadcn@latest add @wisty/metric-trend

The CLI installs the Radix or Base UI file to match your project, with what it builds on: card, chart, metric, tabs.

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.

Don't use it

  • For a row of numbers without their history. Use KPI row.
  • For one number on its own. Use Metric.
  • For a trend people need to read closely, with several series or a target. Use Chart.
  • For more than four metrics. Split them over two cards. Past four, the tabs stop reading as a row.

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`.

Support health
Sep 16 to 29 against the two weeks before, as of Sep 25
Open cases, These two weeks and Two weeks before
These two weeksOpen casesTwo weeks before
Sep 161,4021,310, Sep 2
Sep 171,3771,342, Sep 3
Sep 181,3511,365, Sep 4
Sep 191,3301,371, Sep 5
Sep 201,3181,380, Sep 6
Sep 211,2961,388, Sep 7
Sep 221,2841,392, Sep 8
Sep 231,3011,385, Sep 9
Sep 241,2901,379, Sep 10
Sep 251,2751,394, Sep 11
Sep 26No data1,401, Sep 12
Sep 27No data1,398, Sep 13
Sep 28No data1,405, Sep 14
Sep 29No data1,392, Sep 15

API reference

MetricTrendTabs

PropTypeDefaultDescription
aria-labelstringNames the tabs when no title on the page does.
aria-labelledbystringThe id of the card title that names the tabs.
metrics*MetricTrendMetric[]Two to four metrics, in tab order.
valuestringThe selected metric's id, controlled.
defaultValuestringThe metric to start on when uncontrolled. Default the first.
onValueChange(id: string) => voidCalled when a tab is chosen.
periodstring"This period"This period's name, in the tooltip and legend. Default "This period".
comparisonstring"Previous period"The comparison period's name. Default "Previous period".
currencystringAn ISO 4217 code for metrics with no format: their values read as amounts in it. Without one they read as plain numbers.
localestring"en-US"A BCP 47 locale for numbers. Default "en-US".

Also takes every <div> (opens in a new tab) prop.

MetricTrendPointtype

MemberTypeDefaultDescription
label*stringThe bucket in this period, like "Week of Sep 8".
value*number | nullnull leaves a gap, as for a week still to come.
previousnumber | nullThe comparable bucket's value in the comparison period.
previousLabelstringThat bucket's label, like "Week of Jun 9", shown in the tooltip.

MetricTrendMetrictype

One metric, as a tab and its chart.

MemberTypeDefaultDescription
color1 | 2 | 3 | 4 | 5 | 6Which 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*stringWhat's measured, in sentence case: "Open pipeline".
value*number | nullThe current value. null shows "No data" and no change.
previousnumber | nullThe comparison period's value. Shows the change against it.
formatIntl.NumberFormatOptionsHow the value reads, as Intl.NumberFormat options. Default an amount in the block's currency, or a plain number without one.
isIncreasePositivebooleanWhether 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-labelledby names the tab list: point it at the card's title, or pass aria-label when 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, previous and every point, never formatted strings, and null for 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: false on 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 previousLabel when it has a previous, so the tooltip names both dates.
  • Pass amounts in one currency and set currency to match, or give the metric its own currency format. 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) or aria-label.
  • Put the block directly in the Card, never in CardContent.
  • Never pass asChild or render.