10K010K20K

Monthly revenue

+162%+001234567890012345678900123456789%

8.4K in Sept21K in Nov

JulAugSeptOctNov
ComponentsKPI Chart

KPI Chart

New

A growth curve that draws itself in, with a dot you click along the line and a card whose digits roll to the change.

Install it in one line

Available in v1.7.0

$npx devignerui add kpi-chart

Writes components/ui/kpi-chart.tsx and lists what it needs.

First time here

$npx devignerui init

Or by hand

  1. Needs React 18 or 19 and Tailwind CSS v4.
  2. Install the dependencies.

    $npm i cn motion @devigner-ui/icons devignerui
  3. Paste the source from into components/ui/kpi-chart.tsx.

Usage

import { KpiChart } from "@/components/ui/kpi-chart";

const revenue = [
  { label: "Jul", value: 5000 },
  { label: "Aug", value: 6200 },
  { label: "Sept", value: 8400 },
  { label: "Oct", value: 13100 },
  { label: "Nov", value: 21000 },
];

export function Example() {
  return (
    <KpiChart
      data={revenue}
      label="Monthly revenue"
    />
  );
}

Custom composition

<KpiChart data={revenue} label="Revenue" currency="USD" classNames={{ chart: "text-orange-500" }} />

Currency and a custom accent

Show US dollars and color the line with a palette color.

import { KpiChart } from "@/components/ui/kpi-chart";

const revenue = [
  { label: "Aug", value: 5000 },
  { label: "Sept", value: 8400 },
  { label: "Oct", value: 13100 },
  { label: "Nov", value: 21000 },
];

export function RevenueChart() {
  return (
    <KpiChart
      data={revenue}
      label="Monthly revenue"
      currency="USD"
      classNames={{ chart: "text-orange-500" }}
    />
  );
}

Every prop

data-
{ id?, label, value }[]

The points, left to right, evenly spaced. Each label is shown under its point.

label-
string

Small title at the top of the card, also the chart's accessible name.

localeen-US
string

Locale for the default number format and for where the currency symbol goes.

formatValuecompact numbers in locale (20K)
function

Turns a number into text for the axis, the card and screen readers. Leave the currency out; use currency for it.

formatPointvalue in label (21K in Nov)
(point, value) => string

The line under the change in the card. Gets the point and its value already formatted with the currency.

index-
number

Selected point, controlled. Pair with onIndexChange.

onIndexChange-
(index: number) => void

Called with the point a click, tap, drag or arrow key picks.

defaultIndexlast point
number

Point selected until one is picked. A live series keeps following its newest point until then.

currency-
string

ISO 4217 code: USD, EUR, SEK, XOF. The locale decides the symbol, its side and its spacing ($20K, 20K €).

classNames-
KpiChartClassNames

Slot classes: chart (its text color is the accent) and tooltip.

How to use it

The live demo is five months of revenue, Jul to Nov. The line draws itself in from Jul with a dot riding its tip, with a soft fill under it, while a card pops up at the top centre reading Monthly revenue, +320% with a trend chip, and 21K in Nov. Hover the chart and a small ring marks the nearest month; click and the dot glides along the curve to it, the card's digits roll to the new change while the sign and % hold still, and the line and fill past that month fade back.

When to use it

  • A hero or dashboard tile that tells one growth story, such as revenue, signups or users over a few months.
  • Short series of about 3 to 12 points where the change since the start is the headline.

When to reach for something else

  • Exact reading of values, several series or a real value axis. The y axis only marks a few round values; use a charting library.
  • Comparing separate categories. Use Chart Card, whose bars each stand alone.

Keyboard

Tab
Focuses the chart.
Arrow Left / Arrow Right
Moves the dot to the previous or next point, the same as a click; Arrow Down and Arrow Up do the same.
Home / End
Jumps to the first or last point.

Accessibility

  • The plot is one focusable element with role="slider", named by label, with aria-valuetext reading the focused point, for example "Nov: 21K, +320%".
  • Axis labels, the dot and the card are aria-hidden because the slider already speaks the focused point.
  • Hovering with a mouse only marks a point; the dot, the card and the spoken value change on a click, a tap, a touch drag along the line or an arrow key, and stay there after the pointer or focus leaves.
  • Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too.

Theming

  • The accent is the text color of the chart slot: the line, the dot, its halo and the focused month all use currentColor. It defaults to text-primary; pass classNames.chart="text-orange-500" for another accent.
  • The card is bg-background with a soft shadow ring and foreground text, so it stays neutral whatever the accent. Its trend chip uses the accent for a rise and the destructive token for a drop. Extra classes go through classNames.tooltip.
  • The fill under the line is the accent at 16% fading to clear at the bottom, and shares the line's faded start.
  • The base is bg-background; under a .dark ancestor it is var(--foreground) mixed 9% into var(--background), the same tone as the Nav Notch track.
  • Gridlines use stroke-border and fade out toward their top and bottom; idle labels use text-muted-foreground.

Edge cases

  • The card shows the change since the first point, rounded to a whole percent, with the focused value and label under it (21K in Nov). When the first value is 0 it shows the formatted value instead of a percentage.
  • A point above the first value gets a rising trend chip, a point below it a falling one in the destructive color, and the first point or an equal value fades the chip out while keeping its space. The arrow animates only when the trend changes (none to rising, rising to falling): it turns and slides in along the way it points; moving between two points with the same trend leaves it still.
  • The y axis uses round steps (1, 2, 2.5 or 5 times a power of ten), starts on a step at or below the lowest value and ends at the highest value, so the top of the line sits near the top of the chart and the top tick can be just below it.
  • The curve is monotone: it never dips below or rises above the points it joins, so growth data never shows a fake dip.
  • Points are spaced evenly whatever their labels say; the chart has no time axis.
  • Past 8 points, only every nth label and gridline is drawn, counted back from the last so the newest point is always named. The focused point always shows its label, and thinned labels too close to it are hidden.
  • The trend chip follows the rounded figure, so a change that rounds to 0% shows no arrow.
  • A series where every value is the same draws a flat line across the middle.
  • The card stays fixed at the top centre of the chart while the dot travels; only its figures change. When the dot passes under it, the card fades to a quarter so the dot stays visible.
  • The card keeps one width for the whole series: it makes room for the longest figure and value line across every point, so picking another point never resizes it.
  • The y axis column is at least 3rem wide, so short labels never move it; a label longer than that widens the column instead of spilling past it.
  • The blank right of the plot mirrors the blank left of the y labels (the column minus the widest label), so the chart sits balanced in the card. When the tick text changes length with a new formatValue, that right blank eases to the new size.
  • currency takes an ISO 4217 code (USD, EUR, SEK, XOF). An invalid code throws, the same as Intl.NumberFormat.
  • The locale decides where the symbol goes and how it is spaced: $20K in en-US, 20K € in de-DE, F CFA 20K for XOF in en-US. The digits still come from formatValue.
  • On the y axis the symbol shows once, above the ticks, as the axis unit; the ticks stay plain numbers.
  • From 1000% the change reads in thousands: +1K%, +1.1K%, then whole thousands from +10K%.
  • Only the digits roll, one wheel per place, keyed from the right so the ones digit stays put when the figure gains a digit. The sign, %, separators and any letters from formatValue (the K in 21K) hold still.

Troubleshooting

The card shows a value instead of a percentage.

The first point's value is 0, where a change in percent has no meaning. Start the series at a non-zero value.

The line is black or gray instead of colored.

The accent follows the primary token, which many themes set to near black. Pass a text color in classNames.chart.

The component renders with no background or the wrong colors.

Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.

Stuck on something else? Ask in the Discord.

Some of these designs take cues from interfaces we've admired around the web. If one started as your idea and you'd like credit, message us on X @devignerui or in the Discord and we'll add it.