Kpi

One number, its change coloured by what it means, and a sparkline of how it got there.

Open Source

Installation

Kpi ships with the library — install it once, then import:

import { Kpi } from "@delacour/react-native-ui/kpi";

Or copy the source into your project, to own and edit it:

bunx delacour@latest add kpi

Usage

app/index.tsx
import { Kpi } from "@/components/ui/kpi";
import { View } from "react-native";

const revenue = [31.2, 33.8, 32.9, 36.4, 35.1, 38.7, 41.2, 40.3, 43.9, 48.1];

export default function Example() {
  return (
    <View className="flex-1 justify-center p-4">
      <Kpi>
        <Kpi.Header>
          <Kpi.Title>Revenue</Kpi.Title>
        </Kpi.Header>
        <Kpi.Content>
          <Kpi.Stat>
            <Kpi.Value>$48,120</Kpi.Value>
            <Kpi.Trend caption="vs last month" value={7.8} />
          </Kpi.Stat>
          <Kpi.Sparkline data={revenue} />
        </Kpi.Content>
      </Kpi>
    </View>
  );
}

Anatomy

<Kpi colorIndex={1} goodDirection="up">
  <Kpi.Header>
    <Kpi.Icon />
    <Kpi.Title />
    <Kpi.Action />
  </Kpi.Header>
  <Kpi.Content layout="below">
    <Kpi.Stat>
      <Kpi.Value />
      <Kpi.Trend />
    </Kpi.Stat>
    <Kpi.Sparkline />
  </Kpi.Content>
  <Kpi.Footer />
</Kpi>

<Kpi.Group orientation="horizontal" separated>…</Kpi.Group>

A Kpi is a Card: it takes the card's four fills and three sizes, and the header and content sit on the card's own inset, so the number lines up with the footer beneath it. Kpi.Stat holds the value and its change together — they are one fact in two lines, and the card's gap is for the space between facts.

Which way is good

Colour comes from what the movement means, not from its sign. A fall in churn, refunds or latency is good news, and is drawn as good news — say so once on the card with goodDirection and every trend inside follows. "none" is for a number that is neither, like a headcount.

Which way is good
<Kpi goodDirection="up">
  <Kpi.Trend value={7.8} />
</Kpi>

<Kpi goodDirection="down">
  <Kpi.Trend value={-8.4} />
</Kpi>

<Kpi goodDirection="none">
  <Kpi.Trend value={2.2} />
</Kpi>

The sign is the direction, so there is no direction prop to keep in step with the value. A movement no larger than threshold counts as none, and a value that is not finite — a change against a zero baseline — is flat rather than news of either kind.

Trend variants

text is a line of colour under the number. badge puts a pill round it, with an arrow that says the direction again in a shape — for a card busy enough that a bare line of colour is lost in it.

<Kpi.Trend caption="vs last month" value={12.4} />
<Kpi.Trend value={-3.1} variant="badge" />
<Kpi.Trend format={(value) => `+${value} users`} value={120} />

The default prints one decimal place with an explicit sign and a typographic minus, so a column of changes lines up on the sign. A screen reader hears one string — "Up 7.8 percent, vs last month".

Sizes

The card's sm, md and lg. One axis moves the inset, the value's scale, the change and the sparkline's height together.

<Kpi size="sm">…</Kpi>
<Kpi size="lg">…</Kpi>

Inline

layout="inline" sets the sparkline in a fixed 128pt column beside the number, unfilled. A stack of cards has labels of every length, and a fixed column keeps the shapes lined up down the right-hand edge.

Inline
Inline
<Kpi size="sm">
  <Kpi.Content layout="inline">
    <Kpi.Stat>
      <Kpi.Title>Orders</Kpi.Title>
      <Kpi.Value>1,284</Kpi.Value>
      <Kpi.Trend value={4.1} />
    </Kpi.Stat>
    <Kpi.Sparkline data={orders} />
  </Kpi.Content>
</Kpi>

Sparkline

A Chart line with no grid, no axes and no padding, in the card's series colour — colorIndex on the card picks the slot of the theme's ramp, and Kpi.Icon takes the same one. It takes a bare list of numbers, or rows with an xKey and a yKey. Its y bounds are the data's own, padded a tenth each way, so the peak and trough are never drawn half off the frame.

<Kpi.Sparkline data={[3, 4, 3.6, 5, 4.8, 6]} />
<Kpi.Sparkline data={rows} xKey="day" yKey="visits" />
<Kpi.Sparkline colorIndex={4} data={values} filled={false} strokeWidth={1.5} />

Scrub

Hold the sparkline and drag. A dot rides the line and the KPI's activeIndex follows the finger, going back to null when it lets go. useKpi() reads it, so a part of your own prints the scrubbed point in place of the latest.

Scrub
function ScrubbedValue() {
  const { activeIndex } = useKpi();
  return <Kpi.Value>{format(values[activeIndex ?? values.length - 1])}</Kpi.Value>;
}

<Kpi>
  <Kpi.Content>
    <Kpi.Stat>
      <ScrubbedValue />
    </Kpi.Stat>
    <Kpi.Sparkline data={values} />
  </Kpi.Content>
</Kpi>

It is controllable like any other state: pass activeIndex and onActiveIndexChange to hold it yourself, or defaultActiveIndex to seed it. interactive={false} turns the scrub off — for a sparkline inside a Pressable, say.

const [activeIndex, setActiveIndex] = useState<number | null>(null);

<Kpi activeIndex={activeIndex} onActiveIndexChange={setActiveIndex}>…</Kpi>

Loading

isLoading swaps the value, the change and the sparkline for placeholders of their own size and marks the card busy, so nothing jumps when the data lands.

<Kpi isLoading={isFetching}>…</Kpi>

Groups

Kpi.Group lays several metrics out in a row or a column. In a row each takes an equal share of the width. separated sets them on one surface with a rule between each — one panel rather than several that happen to sit side by side.

Group
Group
<Kpi.Group orientation="horizontal" separated>
  <Kpi size="sm">…</Kpi>
  <Kpi goodDirection="down" size="sm">…</Kpi>
</Kpi.Group>

Composition

A KPI is layout, like the card it is built on. One that selects is a Pressable around it, which keeps the role and the haptic at the call site that knows what the press means.

Dashboard
<Pressable
  accessibilityRole="radio"
  accessibilityState={{ checked: isSelected }}
  haptic="selection"
  onPress={() => setMetric("revenue")}
>
  <Kpi className={isSelected ? "border-primary" : undefined} size="sm">
    <Kpi.Content layout="inline">
      <Kpi.Stat>
        <Kpi.Title>Revenue</Kpi.Title>
        <Kpi.Value>$48,120</Kpi.Value>
        <Kpi.Trend value={7.8} />
      </Kpi.Stat>
      <Kpi.Sparkline data={revenue} interactive={false} />
    </Kpi.Content>
  </Kpi>
</Pressable>

API Reference

Kpi

Extends Card's props.

Prop

Type

Kpi.Header

The top row — an icon, the title, and an action pushed to the end. Extends ViewProps.

Kpi.Icon

A square tinted in the series colour. An unstyled Icon inside takes that colour and a size to match. Extends ViewProps.

Kpi.Title

The metric's name, muted, on one line. Extends Text's props.

Kpi.Action

The header's trailing end — a menu, a filter, a link. Extends ViewProps.

Kpi.Content

Extends ViewProps.

Prop

Type

Kpi.Stat

The value and its change, stacked as one fact. Extends ViewProps.

Kpi.Value

The number, formatted by you, on one line. Extends Text's props.

Kpi.Trend

Extends ViewProps.

Prop

Type

Kpi.Sparkline

Prop

Type

Kpi.Footer

Card.Footer, with bare text set as a muted caption. variant="band" sets it into the card.

Kpi.Group

Extends ViewProps.

Prop

Type

useKpi

Reads the enclosing KPI, for a part of your own. Throws outside a Kpi.

Prop

Type

On this page