/**
* Kpi — one number, and what it is doing.
*
* A metric card is not a chart with a caption. The number is the message and
* the chart is the footnote, so the parts here are sized and ordered around
* that: a title that stays quiet, a value that does not, a trend that says
* which way and by how much, and a sparkline that is allowed to be small
* because nobody is reading values off it.
*
* ```tsx
*
*
* Active users
*
*
* 12,480
*
*
*
*
* ```
*
* The trend is given a number rather than a written string, so the component
* decides the direction and the colour rather than the call site remembering
* to keep a minus sign and a red in step. Where a rise is the bad news —
* churn, latency, cost — say so with `goodDirection="down"` and the colour
* follows the meaning instead of the sign.
*/
import { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';
import { View, type ViewProps } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '../../icons';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { type SeriesColorIndex } from '../../utils/chart';
import { LineChart, type LineChartDatum } from '../line-chart';
import { Progress } from '../progress';
import { Surface } from '../surface';
/** Height of the sparkline when nothing else is said. */
const CHART_HEIGHT = 56;
/**
* …and its box beside the number, which is fixed rather than shared out.
*
* A column of stat cards has labels of every length — "Revenue" over "New
* customers" — and a chart taking whatever the text leaves would be a
* different width on every card in the stack. Fixed, the shapes line up down
* the right-hand edge and the eye can compare them, which is the only reason
* they are there.
*/
const INLINE_CHART_WIDTH = 128;
const INLINE_CHART_HEIGHT = 56;
/** Width ÷ height of the sparkline in that box. */
const INLINE_ASPECT = 2.3;
/** The arrow in a trend badge. Small — it is a direction, not an icon. */
const TREND_ICON = 12;
const TREND_STROKE = 2.5;
const kpiVariants = tv({
slots: {
root: 'w-full gap-2',
header: 'flex-row items-center gap-2',
icon: 'h-8 w-8 items-center justify-center rounded-xl',
title: 'text-sm font-medium text-muted-foreground',
content: 'flex-row items-end justify-between gap-3',
value: 'text-2xl font-bold text-foreground',
/** The stacked title/value/trend block, tight enough to read as one thing. */
stat: 'flex-1 gap-1',
trend: 'flex-row items-center gap-1',
trendLabel: 'font-medium',
footer: 'flex-row items-center gap-2',
separator: 'h-px w-full bg-border',
group: 'w-full',
groupSeparator: 'bg-border',
},
variants: {
/**
* Which way the number moved, *after* the metric's own polarity has been
* applied — so `good` is green whether it went up or down.
*/
tone: {
good: { trendLabel: 'text-success', icon: 'bg-success-subtle' },
bad: { trendLabel: 'text-destructive', icon: 'bg-destructive-subtle' },
flat: { trendLabel: 'text-muted-foreground', icon: 'bg-muted' },
neutral: { trendLabel: 'text-muted-foreground', icon: 'bg-secondary' },
},
/**
* How the change is drawn.
*
* `text` is the quieter of the two and the default: a line of colour under
* the number, which is where the eye already is. `badge` puts a pill round
* it for a card dense enough that a bare line of colour gets lost.
*/
trendVariant: {
text: { trend: 'gap-0.5', trendLabel: 'text-sm' },
badge: { trend: 'rounded-full px-2 py-0.5', trendLabel: 'text-xs' },
},
/** Where the chart sits relative to the number. */
layout: {
/** Under everything, full width. The chart is a footnote. */
below: {},
/**
* Beside the number: the text takes the width and the chart takes a
* fixed column on the end. The number is read first and the shape
* second, which is the order they sit in — so this is the shape a stat
* card wants, and `below` is for a chart big enough to be looked at.
*/
inline: { content: 'items-center gap-4' },
},
},
compoundVariants: [
{ trendVariant: 'badge', tone: 'good', class: { trend: 'bg-success-subtle' } },
{ trendVariant: 'badge', tone: 'bad', class: { trend: 'bg-destructive-subtle' } },
{ trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },
{ trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },
],
defaultVariants: {
tone: 'neutral',
trendVariant: 'text',
layout: 'below',
},
});
type KpiVariantProps = VariantProps;
/** How a trend is coloured once the metric's polarity has been applied. */
export type KpiTone = NonNullable;
/** Which direction of movement is the good news for this metric. */
export type KpiGoodDirection = 'up' | 'down' | 'none';
interface KpiContextValue {
colorIndex: SeriesColorIndex;
goodDirection: KpiGoodDirection;
}
const KpiContext = createContext(null);
/**
* Whether the part rendering is inside `Kpi.Header`.
*
* `Title` is the only part that cares, and it cares a great deal. In a header
* it is one of several things on a row and has to take the space between the
* icon and the actions, so it grows. Written straight into the card it is one
* of several things in a *column* — and a growing child of a column absorbs
* the leftover height, which pushes the number under it down by however much
* that card had spare. Three of those side by side is three numbers at three
* different heights, which is exactly what a row of metrics must not be.
*/
const KpiHeaderContext = createContext(false);
/**
* Whether the part rendering is inside `Kpi.Stat`.
*
* `Trend` is the one that cares. `self-center` on a flex child means "centre
* on the cross axis", and the cross axis is not the same axis in the two
* places a trend is put: in a row it is vertical, which is what a badge on the
* end of a header wants, and in the stat column it is *horizontal* — which
* indents the change by half whatever width it did not use, so two rows with
* captions of different lengths start at two different places.
*/
const KpiStatContext = createContext(false);
function useKpi(part: string): KpiContextValue {
const context = useContext(KpiContext);
if (!context) throw new Error(`${part} must be used inside .`);
return context;
}
/* ------------------------------------------------------------------ *
* Root.
* ------------------------------------------------------------------ */
export interface KpiProps extends Omit {
className?: string;
/**
* Which `--color-chart-*` token the sparkline and the icon take. Set on the
* card rather than on the chart so a row of cards can be given five
* different series colours without repeating the choice on every part.
*/
colorIndex?: SeriesColorIndex;
/**
* Which way is the good news. `up` for revenue and signups, `down` for churn
* and latency, `none` for a number that is neither — a headcount, a version.
* Defaults to `up`.
*/
goodDirection?: KpiGoodDirection;
/** Draw the card on a surface. Turn off to place it in a shell of your own. */
surface?: boolean;
children: ReactNode;
}
const KpiRoot = forwardRef(function KpiRoot(
{ className, colorIndex = 1, goodDirection = 'up', surface = true, children, ...props },
ref
) {
const { root } = kpiVariants();
const context = useMemo(() => ({ colorIndex, goodDirection }), [colorIndex, goodDirection]);
const body = (
{children}
);
return (
{surface ? (
{body}
) : (
body
)}
);
});
/* ------------------------------------------------------------------ *
* Header, and the things that live in it.
* ------------------------------------------------------------------ */
export interface KpiHeaderProps extends ViewProps {
className?: string;
children: ReactNode;
}
/** The top row: an icon, the metric's name, and anything acting on it. */
function KpiHeader({ className, children, ...props }: KpiHeaderProps) {
const { header } = kpiVariants();
return (
{children}
);
}
export interface KpiIconProps extends ViewProps {
className?: string;
/** Overrides the tint the card's `colorIndex` would give it. */
tone?: KpiTone;
children: ReactNode;
}
/**
* A tinted square for a glyph.
*
* It takes the element rather than drawing one, because a metric's icon comes
* from whatever set the app already uses — and an icon from outside this
* library will not read an ambient colour, so pass it one.
*/
function KpiIcon({ className, tone = 'neutral', children, ...props }: KpiIconProps) {
const { icon } = kpiVariants({ tone });
return (
{children}
);
}
export interface KpiTitleProps {
className?: string;
children: ReactNode;
}
/** The metric's name. Quiet on purpose — the value is the thing being read. */
function KpiTitle({ className, children }: KpiTitleProps) {
const { title } = kpiVariants();
// Grows across a header row; never down a column — see `KpiHeaderContext`.
const inHeader = useContext(KpiHeaderContext);
return (
{children}
);
}
export interface KpiStatProps extends ViewProps {
className?: string;
children: ReactNode;
}
/**
* The stacked title / value / change block.
*
* Its own container rather than three loose children of the card, because the
* three belong together more tightly than they belong to whatever is above or
* below them — 4pt between the lines of one fact, and the card's own spacing
* between facts. It also takes the width in an `inline` row, leaving the chart
* its column on the end.
*/
function KpiStat({ className, children, ...props }: KpiStatProps) {
const { stat } = kpiVariants();
return (
{children}
);
}
export interface KpiActionsProps extends ViewProps {
className?: string;
children: ReactNode;
}
/** The trailing end of the header — a menu trigger, a filter, a link. */
function KpiActions({ className, children, ...props }: KpiActionsProps) {
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* The number, and what it is doing.
* ------------------------------------------------------------------ */
export interface KpiContentProps extends ViewProps {
className?: string;
/** `inline` puts the chart beside the value instead of under everything. */
layout?: NonNullable;
children: ReactNode;
}
/** The row the value and the trend share. */
function KpiContent({
className,
layout = 'below',
children,
...props
}: KpiContentProps) {
const { content } = kpiVariants({ layout });
return (
{children}
);
}
export interface KpiValueProps {
className?: string;
children: ReactNode;
}
/**
* The number.
*
* Formatted by the caller, not here: thousands separators, currency symbols
* and units are locale decisions, and a component that guessed them would be
* wrong in a way that is hard to notice and impossible to override.
*/
function KpiValue({ className, children }: KpiValueProps) {
const { value } = kpiVariants();
return (
{children}
);
}
export interface KpiTrendProps extends Omit {
className?: string;
/**
* How much it moved, as a percentage. The sign carries the direction, so
* `-4.2` is a fall of 4.2%; there is no separate direction prop to keep in
* step with it.
*/
value: number;
/**
* Writes the number yourself. Receives the raw value, sign and all. The
* default prints one decimal place with an explicit `+` or `−`.
*/
format?: (value: number) => string;
/** Overrides the card's own `goodDirection` for this one figure. */
goodDirection?: KpiGoodDirection;
/**
* `text` is a line of colour under the number — the default, and what a
* stat card usually wants. `badge` puts a pill round it, with an arrow, for
* a card busy enough that a bare line of colour is lost in it.
*/
variant?: NonNullable;
/** What it is being compared against — "last 30d", "vs last week". */
caption?: string;
/** Anything after the number, when a caption is not enough. */
children?: ReactNode;
/** Below which a movement counts as no movement. Defaults to `0`. */
threshold?: number;
}
/**
* The change.
*
* Colour comes from what the movement *means*, not from its sign: a fall in
* churn is good news and is drawn as good news. That is the whole reason this
* takes a number rather than a string — a caller writing "−4.2%" into a green
* label has to remember to change the colour when the metric changes, and
* nobody does.
*/
function KpiTrend({
className,
value,
format,
goodDirection,
variant = 'text',
caption,
threshold = 0,
children,
...props
}: KpiTrendProps) {
const context = useKpi('Kpi.Trend');
const polarity = goodDirection ?? context.goodDirection;
const flat = Math.abs(value) <= threshold;
const rising = value > 0;
const tone: KpiTone = flat
? 'flat'
: polarity === 'none'
? 'neutral'
: (rising && polarity === 'up') || (!rising && polarity === 'down')
? 'good'
: 'bad';
// Down a column it starts at the leading edge, level with the title and the
// number above it; across a row it centres against them — see `KpiStatContext`.
const inStat = useContext(KpiStatContext);
const { trend, trendLabel } = kpiVariants({ tone, trendVariant: variant });
// The arrow's colour, resolved so it matches the label beside it. An icon
// from outside this library does not inherit a text colour.
const goodTint = useCSSVariable('--color-success');
const badTint = useCSSVariable('--color-destructive');
const mutedTint = useCSSVariable('--color-muted-foreground');
const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;
const tint = typeof raw === 'string' ? raw : '#737373';
const Arrow = flat ? MinusIcon : rising ? ArrowUpIcon : ArrowDownIcon;
const label = format
? format(value)
: // A true minus sign rather than a hyphen: at this size a hyphen reads as
// a dash between two words, and the sign is half the meaning.
`${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;
return (
{/* No arrow in `text`: the sign is already in front of the number, and
drawing both says the same thing twice in the same three points. */}
{variant === 'badge' ? (
) : null}
{label}
{/* One Text, not two: a caption in its own element wraps onto its own
line the moment the card gets narrow, which reads as a second fact
rather than the rest of this one. */}
{caption ? {caption} : null}
{children}
);
}
/* ------------------------------------------------------------------ *
* The chart, and the bar.
* ------------------------------------------------------------------ */
export interface KpiSparklineProps {
className?: string;
/** The rows. One point each, in order. */
data: LineChartDatum[];
/** Key holding the y values. */
dataKey: string;
/** Overrides the card's `colorIndex`. */
colorIndex?: SeriesColorIndex;
/**
* Fill under the line. Off beside the number, where the chart is a gesture
* and a fill would make it a second block competing with the value; on when
* it has the full width under everything and is being looked at properly.
*/
filled?: boolean;
/** Height in points. */
height?: number;
/**
* Put it beside the number, taking whatever width the text leaves rather
* than a column of its own. Pair with `layout="inline"` on the content row.
*/
inline?: boolean;
strokeWidth?: number;
}
/**
* The sparkline.
*
* A line chart with the axis padding dropped, which is what `compact` on the
* chart itself means — there is no grid, no axis and no crosshair here, so
* every point of padding is a point the shape is not using. Nobody reads a
* value off one of these; they read whether it is going up.
*/
function KpiSparkline({
className,
data,
dataKey,
colorIndex,
filled,
height,
inline = false,
strokeWidth = 2,
}: KpiSparklineProps) {
const context = useKpi('Kpi.Chart');
const index = colorIndex ?? context.colorIndex;
const fill = filled ?? !inline;
return (
{fill ? : null}
);
}
export interface KpiProgressProps {
className?: string;
/** Where it has got to. */
value: number;
/** The value at which the bar reads as full. Defaults to `100`. */
maxValue?: number;
/** A caption above the bar. */
label?: string;
/** Print the percentage on the right of the caption row. */
showValueLabel?: boolean;
}
/** Progress towards a target, for a metric that has one. */
function KpiProgressBar({
className,
value,
maxValue = 100,
label,
showValueLabel,
}: KpiProgressProps) {
return (
);
}
/* ------------------------------------------------------------------ *
* Footer and separator.
* ------------------------------------------------------------------ */
export interface KpiFooterProps extends ViewProps {
className?: string;
children: ReactNode;
}
/** The bottom strip — a comparison period, a caveat, a link. */
function KpiFooter({ className, children, ...props }: KpiFooterProps) {
const { footer } = kpiVariants();
return (
{children}
);
}
export interface KpiSeparatorProps extends ViewProps {
className?: string;
}
/** A hairline across the card. */
function KpiSeparator({ className, ...props }: KpiSeparatorProps) {
const { separator } = kpiVariants();
return ;
}
/* ------------------------------------------------------------------ *
* Group.
* ------------------------------------------------------------------ */
export type KpiGroupOrientation = 'horizontal' | 'vertical';
export interface KpiGroupProps extends ViewProps {
className?: string;
/** `horizontal` splits the row between the cards; `vertical` stacks them. */
orientation?: KpiGroupOrientation;
/**
* Draw a hairline between the cards rather than spacing them apart. Several
* metrics separated by a rule read as one panel; several spaced apart read
* as several panels that happen to be adjacent.
*/
separated?: boolean;
children: ReactNode;
}
/**
* Several metrics, laid out as one panel.
*
* The separators are inserted between the children rather than written by the
* caller, because "between" is the one thing a list of siblings cannot express
* — a trailing rule after the last card is the mistake this exists to prevent.
*/
const KpiGroup = forwardRef(function KpiGroup(
{ className, orientation = 'horizontal', separated = true, children, ...props },
ref
) {
const { group, groupSeparator } = kpiVariants();
const horizontal = orientation === 'horizontal';
const items = Array.isArray(children) ? children.flat() : [children];
const visible = items.filter(Boolean);
return (
{visible.map((child, index) => (
0 && 'ps-4',
separated && horizontal && index < visible.length - 1 && 'pe-4',
separated && !horizontal && index > 0 && 'pt-4',
separated && !horizontal && index < visible.length - 1 && 'pb-4'
)}
>
{separated && index > 0 ? (
) : null}
{child}
))}
);
});
KpiRoot.displayName = 'Kpi';
KpiHeader.displayName = 'Kpi.Header';
KpiIcon.displayName = 'Kpi.Icon';
KpiTitle.displayName = 'Kpi.Title';
KpiStat.displayName = 'Kpi.Stat';
KpiActions.displayName = 'Kpi.Actions';
KpiContent.displayName = 'Kpi.Content';
KpiValue.displayName = 'Kpi.Value';
KpiTrend.displayName = 'Kpi.Trend';
KpiSparkline.displayName = 'Kpi.Chart';
KpiProgressBar.displayName = 'Kpi.Progress';
KpiFooter.displayName = 'Kpi.Footer';
KpiSeparator.displayName = 'Kpi.Separator';
KpiGroup.displayName = 'Kpi.Group';
export const Kpi = Object.assign(KpiRoot, {
Header: KpiHeader,
Icon: KpiIcon,
Title: KpiTitle,
Stat: KpiStat,
Actions: KpiActions,
Content: KpiContent,
Value: KpiValue,
Trend: KpiTrend,
Chart: KpiSparkline,
Progress: KpiProgressBar,
Footer: KpiFooter,
Separator: KpiSeparator,
Group: KpiGroup,
});