Data
Stat
A key figure with its label, its change from the previous period and room for a small chart.
- Built on
Intl.NumberFormat- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.3 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Stat } from "@/components/equip/stat";
export default function Demo() {
return <Stat label="Monthly active teams" value={4632} previous={4120} goodWhen="up" hint="October 2026" />;
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Monthly active teams</span><span class="eq-stat-figure"><span class="eq-stat-value">4,632</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+12.4%</span><span class="eq-stat-said">Up 12.4%</span><span class="eq-stat-previous">from 4,120</span></span><span class="eq-stat-hint">October 2026</span></div>
</div>Examples
When down is good
goodWhen decides the tone; the arrow and the signed text say the direction, so colour is never the only cue.
Code
import { Stat } from "@/components/equip/stat";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 32 }}>
<Stat label="p95 latency" value={182} unit="ms" previous={240} goodWhen="down" />
<Stat label="Failed deploys" value={9} previous={6} goodWhen="down" />
<Stat label="Open incidents" value={3} previous={3} goodWhen="down" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:32px">
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">p95 latency</span><span class="eq-stat-figure"><span class="eq-stat-value">182</span><span class="eq-stat-unit">ms</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="down"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">−24.2%</span><span class="eq-stat-said">Down 24.2%</span><span class="eq-stat-previous">from 240</span></span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Failed deploys</span><span class="eq-stat-figure"><span class="eq-stat-value">9</span></span><span class="eq-stat-delta" data-tone="danger" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+50%</span><span class="eq-stat-said">Up 50%</span><span class="eq-stat-previous">from 6</span></span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Open incidents</span><span class="eq-stat-figure"><span class="eq-stat-value">3</span></span><span class="eq-stat-delta" data-tone="neutral" data-direction="flat"><span class="eq-stat-change" aria-hidden="true">No change</span><span class="eq-stat-said">No change</span><span class="eq-stat-previous">from 3</span></span></div>
</div>
</div>Currency, percent and locale
format and locale go straight to Intl.NumberFormat; the previous figure is formatted the same way.
Code
import { Stat } from "@/components/equip/stat";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 32 }}>
<Stat
label="Monthly recurring revenue"
value={128400}
previous={117900}
goodWhen="up"
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>
<Stat label="Churn" value={0.021} previous={0.018} goodWhen="down" format={{ style: "percent", maximumFractionDigits: 1 }} />
<Stat label="Umsatz" value={98250} previous={91000} goodWhen="up" locale="de-DE" format={{ style: "currency", currency: "EUR", maximumFractionDigits: 0 }} />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:32px">
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Monthly recurring revenue</span><span class="eq-stat-figure"><span class="eq-stat-value">$128,400</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+8.9%</span><span class="eq-stat-said">Up 8.9%</span><span class="eq-stat-previous">from $117,900</span></span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Churn</span><span class="eq-stat-figure"><span class="eq-stat-value">2.1%</span></span><span class="eq-stat-delta" data-tone="danger" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+16.7%</span><span class="eq-stat-said">Up 16.7%</span><span class="eq-stat-previous">from 1.8%</span></span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Umsatz</span><span class="eq-stat-figure"><span class="eq-stat-value">98.250 €</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+8 %</span><span class="eq-stat-said">Up 8 %</span><span class="eq-stat-previous">from 91.000 €</span></span></div>
</div>
</div>With a visual
Anything passed as children sits on the right, bottom aligned; a Sparkline is the usual choice.
Code
import { Stat } from "@/components/equip/stat";
export default function Example() {
return (
<Stat label="Builds per day" value={312} previous={286} goodWhen="up" hint="Last 7 days" style={{ maxWidth: 320 }}>
<svg width="96" height="32" viewBox="0 0 96 32" role="img" aria-label="Builds per day, rising from 280 to 312">
<path
d="M2 24 L17 26 L32 20 L47 22 L62 14 L77 12 L94 6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Stat>
);
}HTMLworks in any framework, with the part's CSS
<div style="max-width:320px" class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Builds per day</span><span class="eq-stat-figure"><span class="eq-stat-value">312</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+9.1%</span><span class="eq-stat-said">Up 9.1%</span><span class="eq-stat-previous">from 286</span></span><span class="eq-stat-hint">Last 7 days</span></div>
<div class="eq-stat-visual">
<svg width="96" height="32" viewBox="0 0 96 32" role="img" aria-label="Builds per day, rising from 280 to 312">
<path d="M2 24 L17 26 L32 20 L47 22 L62 14 L77 12 L94 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>Sizes
s is for a sidebar or a dense card and lets the change wrap; l is the one headline figure on a page.
Code
import { Stat } from "@/components/equip/stat";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 32, alignItems: "flex-end" }}>
<Stat size="s" label="Open issues" value={37} previous={42} goodWhen="down" />
<Stat size="m" label="Paying teams" value={1842} previous={1790} goodWhen="up" />
<Stat
size="l"
label="Monthly recurring revenue"
value={128400}
previous={117900}
goodWhen="up"
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:32px;align-items:flex-end">
<div class="eq-stat" data-size="s">
<div class="eq-stat-body"><span class="eq-stat-label">Open issues</span><span class="eq-stat-figure"><span class="eq-stat-value">37</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="down"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">−11.9%</span><span class="eq-stat-said">Down 11.9%</span><span class="eq-stat-previous">from 42</span></span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Paying teams</span><span class="eq-stat-figure"><span class="eq-stat-value">1,842</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+2.9%</span><span class="eq-stat-said">Up 2.9%</span><span class="eq-stat-previous">from 1,790</span></span></div>
</div>
<div class="eq-stat" data-size="l">
<div class="eq-stat-body"><span class="eq-stat-label">Monthly recurring revenue</span><span class="eq-stat-figure"><span class="eq-stat-value">$128,400</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+8.9%</span><span class="eq-stat-said">Up 8.9%</span><span class="eq-stat-previous">from $117,900</span></span></div>
</div>
</div>Change in points
A rate that moves from 1.8% to 2.1% rose 0.3 points, not 16.7%; absolute shows the difference and formatChange names the unit.
Code
import { Stat } from "@/components/equip/stat";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 32 }}>
<Stat
label="Churn"
value={2.1}
unit="%"
previous={1.8}
goodWhen="down"
change="absolute"
formatChange={(delta) => `${delta.toFixed(1)} pts`}
hint="October 2026"
/>
<Stat label="Open support tickets" value={38} previous={52} goodWhen="down" change="absolute" hint="This week" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:32px">
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Churn</span><span class="eq-stat-figure"><span class="eq-stat-value">2.1</span><span class="eq-stat-unit">%</span></span><span class="eq-stat-delta" data-tone="danger" data-direction="up"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">+0.3 pts</span><span class="eq-stat-said">Up 0.3 pts</span><span class="eq-stat-previous">from 1.8</span></span><span class="eq-stat-hint">October 2026</span></div>
</div>
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Open support tickets</span><span class="eq-stat-figure"><span class="eq-stat-value">38</span></span><span class="eq-stat-delta" data-tone="ok" data-direction="down"><svg class="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true"><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg><span class="eq-stat-change" aria-hidden="true">−14</span><span class="eq-stat-said">Down 14</span><span class="eq-stat-previous">from 52</span></span><span class="eq-stat-hint">This week</span></div>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | What the figure counts. |
value | number | string | none | A number is formatted; a string is shown as it is. |
format | Intl.NumberFormatOptions | none | How the value and previous figure are formatted. |
previous | number | none | The previous period. With a numeric value, shows the change as a percent, or as an amount when previous is 0. |
change | "percent" | "absolute" | "percent" | Shows the change as a percent of the previous figure, or as the difference in the value's own units, such as points. |
formatChange | (delta: number) => string | none | Formats the size of the change without a sign; the part adds the sign and arrow. Gets a fraction for percent and the difference for absolute. |
goodWhen | "up" | "down" | none | Which direction is good news. Decides the ok or danger tone; leave out for a neutral change. |
unit | ReactNode | none | Shown after the value in smaller type. |
hint | ReactNode | none | A line under the figure, such as the period it covers. |
locale | string | "en-US" | Passed to Intl.NumberFormat. |
size | "s" | "m" | "l" | "m" | s is compact, with the figure on the largest type step and a change that may wrap; l is a headline figure. |
children | ReactNode | none | A visual on the right, usually a Sparkline. |
...rest | ComponentProps<"div"> | none | Every native div attribute. |
Anatomy
| root | .eq-stat | data-size is s, m or l. |
|---|---|---|
| body | .eq-stat-body | Label, figure, change and hint, stacked. |
| label | .eq-stat-label | |
| figure | .eq-stat-figure | Holds the value and unit on one baseline. |
| value | .eq-stat-value | |
| unit | .eq-stat-unit | |
| delta | .eq-stat-delta | data-tone is ok, danger or neutral; data-direction is up, down or flat. |
| arrow | .eq-stat-arrow | |
| change | .eq-stat-change | The signed figure, hidden from assistive tech. |
| spoken change | .eq-stat-said | Visually hidden words, such as "Up 12.4%". |
| previous | .eq-stat-previous | |
| hint | .eq-stat-hint | |
| visual | .eq-stat-visual |
Notes
- The change reads as words, such as "Up 12.4% from 4,120", and shows an arrow and a sign, so colour is never the only cue.
- No change shows no arrow and a neutral tone; a previous figure of 0 shows the change as an amount, since a percent would be infinite.
- At size m the value is the largest type step times 1.75 and at l times 2.5, the only figures in the library above the type scale; at s it is the largest step itself.
- Stretched taller than its content, as in a grid row, the label and figure stay at the top and a visual sits at the bottom.
- For a rate such as churn, change="absolute" with formatChange shows the move in points, which a percent of a percent would overstate.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/stat.tsx95 lines
import type { ComponentProps, ReactNode } from "react";
import "./stat.css";
export type StatProps = ComponentProps<"div"> & {
/** What the figure counts, such as "Monthly active teams". */
label: ReactNode;
/** A number is formatted with format and locale; a string is shown as it is. */
value: number | string;
format?: Intl.NumberFormatOptions;
/** The figure for the previous period. With a numeric value, shows the change and its percent. */
previous?: number;
/** Show the change as a percent of the previous figure, or as the difference in the value's own units, such as points. */
change?: "percent" | "absolute";
/**
* Formats the size of the change, without a sign: the part adds the sign and the arrow. Receives a fraction
* (0.124) for percent and the difference in value units for absolute.
*/
formatChange?: (delta: number) => string;
/** Which direction is good news. Decides the ok or danger tone; leave out for a neutral change. */
goodWhen?: "up" | "down";
/** Shown after the value in smaller type, such as "ms" or "per seat". */
unit?: ReactNode;
/** A line under the figure, such as the period it covers. */
hint?: ReactNode;
locale?: string;
/** s is compact, for a sidebar or a dense card; l is a headline figure. */
size?: "s" | "m" | "l";
/** A visual on the right, usually a Sparkline. */
children?: ReactNode;
};
export function Stat({
label,
value,
format,
previous,
change: changeAs = "percent",
formatChange,
goodWhen,
unit,
hint,
locale = "en-US",
size = "m",
className,
children,
...rest
}: StatProps) {
const number = new Intl.NumberFormat(locale, format);
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
const shown = typeof value === "number" ? number.format(value) : value;
let delta: ReactNode = null;
if (typeof value === "number" && previous !== undefined && Number.isFinite(previous)) {
const change = value - previous;
const direction = change > 0 ? "up" : change < 0 ? "down" : "flat";
const tone = direction === "flat" || !goodWhen ? "neutral" : direction === goodWhen ? "ok" : "danger";
const amount =
changeAs === "absolute"
? (formatChange ?? number.format)(Math.abs(change))
: previous === 0
? number.format(Math.abs(change))
: (formatChange ?? percent.format)(Math.abs(change / previous));
const from = number.format(previous);
delta = (
<span className="eq-stat-delta" data-tone={tone} data-direction={direction}>
{direction !== "flat" && (
<svg className="eq-stat-arrow" viewBox="0 0 12 12" aria-hidden="true">
<path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" />
</svg>
)}
<span className="eq-stat-change" aria-hidden="true">
{direction === "flat" ? "No change" : `${direction === "up" ? "+" : "−"}${amount}`}
</span>
<span className="eq-stat-said">{direction === "flat" ? "No change" : `${direction === "up" ? "Up" : "Down"} ${amount}`}</span>
<span className="eq-stat-previous">from {from}</span>
</span>
);
}
return (
<div {...rest} className={["eq-stat", className].filter(Boolean).join(" ")} data-size={size}>
<div className="eq-stat-body">
<span className="eq-stat-label">{label}</span>
<span className="eq-stat-figure">
<span className="eq-stat-value">{shown}</span>
{unit != null && <span className="eq-stat-unit">{unit}</span>}
</span>
{delta}
{hint != null && <span className="eq-stat-hint">{hint}</span>}
</div>
{children != null && <div className="eq-stat-visual">{children}</div>}
</div>
);
}components/equip/stat.css141 lines
@layer equip {
.eq-stat {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--eq-space-4);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
color: var(--eq-ink);
}
/* Top-aligned, so a stat stretched by its row keeps its label at the top; the visual stays at the bottom. */
.eq-stat-body {
align-self: flex-start;
display: grid;
justify-items: start;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-stat-label {
color: var(--eq-ink-2);
font-weight: var(--eq-weight-strong);
overflow-wrap: anywhere;
}
.eq-stat-figure {
display: inline-flex;
align-items: baseline;
gap: var(--eq-space-1);
}
/* The one figure in the library bigger than the type scale: built from the largest step. */
.eq-stat-value {
font-size: calc(var(--eq-text-l) * 1.75);
font-weight: var(--eq-weight-strong);
line-height: 1.1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.eq-stat-unit {
color: var(--eq-ink-2);
font-size: var(--eq-text-m);
}
.eq-stat-delta {
--_tone: var(--eq-ink-2);
position: relative;
display: inline-flex;
align-items: center;
gap: var(--eq-space-1);
color: var(--eq-ink-2);
font-variant-numeric: tabular-nums;
}
.eq-stat-delta[data-tone="ok"] {
--_tone: var(--eq-ok);
}
.eq-stat-delta[data-tone="danger"] {
--_tone: var(--eq-danger);
}
.eq-stat-arrow {
flex: none;
width: 12px;
height: 12px;
fill: none;
stroke: var(--_tone);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.eq-stat-delta[data-direction="down"] .eq-stat-arrow {
rotate: 180deg;
}
.eq-stat-change {
color: var(--_tone);
font-weight: var(--eq-weight-strong);
}
.eq-stat-said {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-stat-hint {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
}
/* Compact: the figure on the top step of the type scale, and a change that may wrap. */
.eq-stat[data-size="s"] {
font-size: var(--eq-text-xs);
}
.eq-stat[data-size="s"] .eq-stat-value {
font-size: var(--eq-text-l);
letter-spacing: -0.01em;
}
.eq-stat[data-size="s"] .eq-stat-unit {
font-size: var(--eq-text-xs);
}
.eq-stat[data-size="s"] .eq-stat-delta {
flex-wrap: wrap;
}
/* Headline: one step up for the words, and a figure from the largest step times 2.5. */
.eq-stat[data-size="l"] {
font-size: var(--eq-text-m);
}
.eq-stat[data-size="l"] .eq-stat-value {
font-size: calc(var(--eq-text-l) * 2.5);
}
.eq-stat[data-size="l"] .eq-stat-unit {
font-size: var(--eq-text-l);
}
.eq-stat[data-size="l"] .eq-stat-hint {
font-size: var(--eq-text-s);
}
.eq-stat-visual {
flex: none;
color: var(--eq-ink);
}
}