component — display
Stat
A metric or KPI without a card around it: label, value, and optionally a delta and a trend. StatGroup lays several in a row with hairlines between them and stacks them on small screens; StatDelta signs and formats the change, picks the arrow from its sign and colours it by whether that direction is good. Rendered as a description list, with numbers formatted through FormattedNumber.
- stat
- kpi
- metric
- dashboard
- delta
- trend
- number
use a stat when
- A handful of headline numbers at the top of a dashboard or a detail page.
- One figure that needs its label and how it changed since last period.
don't
- Don't put a Card around each stat. The group's hairlines separate them; a box per number is ink spent on borders.
- More than about five numbers of the same kind is a table or a BarList, where they can be compared down a column.
- A value against a known maximum (quota, capacity) is a Meter or a ProgressBar.
04 examples
A stat row
Label, value, delta and a sparkline, with a hairline between the stats. Below `sm` the row stacks and the lines go, because the gap already separates them. No card around any of it.
- Signups
- 1.284
- +12 %
- Active kiosks
- 312
- +3 %
- Checkout errors
- 17
- -40 %
When a rise is bad
The arrow follows the sign; the colour follows whether that is good news. `invert` is for the numbers you want to fall — errors, latency, churn. A zero change is neutral either way.
- Revenue
- 48.200
- +8,4 %
- p95 latency
- 212 ms
- +21 %
- Churn
- 2,1 %
- -0,4 %
- Seats
- 40
- 0 %
Formatting and captions
A numeric `value` goes through `FormattedNumber` with `formatOptions`, so the locale is the provider's and not the runtime's. `StatDelta` is a signed percentage by default; its `options` make it an absolute change. `caption` says what the delta is measured against.
- Monthly recurring revenue
- 18.430 €
- +1.240 €vs last month
- Onboarding
- 3 of 5
- steps done
Sizes
`lg` for the one number a page is about, `md` (the default) for a row of them, `sm` for a sidebar or a panel header.
- Size lg
- 1.284
- +12 %
- Size md
- 1.284
- +12 %
- Size sm
- 1.284
- +12 %
what to use instead
by jobBy what the stat was doing.
source
stat.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { createContext, type ReactNode, useContext } from "react"
import type { VariantProps } from "tailwind-variants"
import { cn, tv } from "@/lib/utils"
import { Badge } from "@/components/badge"
import { FormattedNumber } from "@/components/formatted-number"
/**
* Stat — quebi design system
*
* One number with its label, and optionally what it did: a delta and a trend.
* No card around it. A number does not need a box to be read, and a row of
* boxes each holding one number is the most common way a dashboard spends its
* ink on borders instead of data. `StatGroup` lays several side by side with a
* hairline between them, and stacks them below `sm`, where the line would
* divide nothing and the gap does the job.
*
* ## Markup
*
* A stat is a term and its description, so it renders as one: `StatGroup` is a
* `<dl>`, each `Stat` is a `<div>` holding a `<dt>` (the label) and `<dd>`s
* (the value, and the delta line). A `Stat` outside a group wraps itself in its
* own `<dl>`, so the markup is valid either way. That is also why the lines
* between stats are borders and not `Separator` elements: a `<dl>` may only
* contain `<div>`, `<dt>` and `<dd>`, and a `role="separator"` between two
* figures says nothing a screen reader user needs.
*
* ## Numbers
*
* Pass `value` a number and it goes through `FormattedNumber` with
* `formatOptions`, so the locale comes from the nearest `I18nProvider` and the
* prerender and the browser agree. Pass a node for anything else ("3 of 5",
* a duration).
*
* ## Deltas
*
* `StatDelta` takes the change as a number, signs and formats it (a percentage
* by default), picks the direction from the sign, and colours it by whether
* that direction is good: up takes the success token, unless `invert` says a rise is bad —
* errors, latency, churn. The direction is also drawn as an arrow, so it is
* never carried by colour alone.
*
* @example
* <StatGroup aria-label="This week">
* <Stat label="Signups" value={1284} delta={<StatDelta value={0.12} />}
* trend={<Sparkline data={signups} />} />
* <Stat label="Checkout errors" value={17} delta={<StatDelta value={-0.4} invert />} />
* </StatGroup>
*/
const InGroupContext = createContext(false)
export const statValueStyles = tv({
base: "font-display font-extralight text-quebi-fg tabular-nums leading-none",
variants: {
// The figure is the headline of a stat, so it takes the display face at
// its thinnest: size carries the emphasis, never weight.
size: {
sm: "text-2xl",
md: "text-4xl",
lg: "text-6xl",
},
},
defaultVariants: {
size: "md",
},
})
export interface StatProps
extends Omit<React.ComponentProps<"div">, "children">,
VariantProps<typeof statValueStyles> {
/** What the number is. Rendered as the `<dt>`. */
label: ReactNode
/** The figure. A number is formatted through `FormattedNumber`; a node is rendered as is. */
value: number | ReactNode
/** `Intl.NumberFormat` options for a numeric `value` — a currency, a percentage, a unit. */
formatOptions?: Intl.NumberFormatOptions
/** How it changed: usually a `StatDelta`. */
delta?: ReactNode
/** How it got here: usually a `Sparkline`. */
trend?: ReactNode
/** What the delta is measured against, e.g. "vs last week". Muted, after the delta. */
caption?: ReactNode
}
export function Stat({
label,
value,
formatOptions,
delta,
trend,
caption,
size,
className,
...props
}: StatProps) {
const inGroup = useContext(InGroupContext)
const hasFooter = delta != null || trend != null || caption != null
const stat = (
<div {...props} data-slot="stat" className={cn("flex min-w-0 flex-col gap-2", className)}>
<dt className="quebi-eyebrow">{label}</dt>
<dd className={statValueStyles({ size })}>
{typeof value === "number" ? (
<FormattedNumber value={value} options={formatOptions} />
) : (
value
)}
</dd>
{hasFooter && (
<dd className="flex flex-wrap items-center gap-2 text-quebi-caption text-quebi-fg-subtle">
{delta}
{trend}
{caption != null && <span>{caption}</span>}
</dd>
)}
</div>
)
return inGroup ? stat : <dl>{stat}</dl>
}
export type StatDirection = "up" | "down" | "flat"
export interface StatDeltaProps extends Omit<React.ComponentProps<"span">, "children"> {
/** The change. Its sign picks the direction; formatted as a signed percentage unless `options` says otherwise. */
value: number
/** `Intl.NumberFormat` options, merged over the percentage default. The sign is always shown. */
options?: Intl.NumberFormatOptions
/** A rise is bad: errors, latency, churn. Swaps the colours, not the arrow. */
invert?: boolean
/** Override the direction the sign implies — e.g. a rounded 0% that is really a small rise. */
direction?: StatDirection
/** Replace the formatted number with your own text. The arrow and colour still follow `value`. */
children?: ReactNode
}
const ARROW_PATHS: Record<StatDirection, string> = {
up: "M6 9.5V2.5M3 5.5l3-3 3 3",
down: "M6 2.5v7M3 6.5l3 3 3-3",
flat: "M2.5 6h7M6.5 3l3 3-3 3",
}
function directionOf(value: number): StatDirection {
if (value > 0) return "up"
if (value < 0) return "down"
return "flat"
}
export function StatDelta({
value,
options,
invert = false,
direction,
children,
...props
}: StatDeltaProps) {
const dir = direction ?? directionOf(value)
const good = invert ? "down" : "up"
const intent = dir === "flat" ? "neutral" : dir === good ? "success" : "danger"
return (
<Badge {...props} intent={intent} data-slot="stat-delta" data-direction={dir}>
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className="size-3 fill-none stroke-current"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={ARROW_PATHS[dir]} />
</svg>
{children ?? (
<FormattedNumber
value={value}
options={{
style: "percent",
maximumFractionDigits: 1,
...options,
signDisplay: "exceptZero",
}}
/>
)}
</Badge>
)
}
export interface StatGroupProps extends React.ComponentProps<"dl"> {}
/**
* A row of stats with a hairline between them, stacked below `sm`. Name it with
* `aria-label` when the page has no heading that already says what the numbers
* are about.
*/
export function StatGroup({ className, children, ...props }: StatGroupProps) {
return (
<InGroupContext.Provider value={true}>
<dl
{...props}
data-slot="stat-group"
className={cn(
"flex flex-col gap-4",
"sm:flex-row sm:gap-8 sm:[&>*+*]:border-l sm:[&>*+*]:border-quebi-hairline sm:[&>*+*]:pl-8",
className,
)}
>
{children}
</dl>
</InGroupContext.Provider>
)
}