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 job

By what the stat was doing.

Many comparable numbers
A value against a limit
A series over time

source

stat.tsx

Copy 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>
  )
}