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
Monthly active teams4,632Up 12.4%from 4,120October 2026
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.

p95 latency182msDown 24.2%from 240
Failed deploys9Up 50%from 6
Open incidents3No changefrom 3
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.

Monthly recurring revenue$128,400Up 8.9%from $117,900
Churn2.1%Up 16.7%from 1.8%
Umsatz98.250 €Up 8 %from 91.000 €
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.

Builds per day312Up 9.1%from 286Last 7 days
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.

Open issues37Down 11.9%from 42
Paying teams1,842Up 2.9%from 1,790
Monthly recurring revenue$128,400Up 8.9%from $117,900
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.

Churn2.1%Up 0.3 ptsfrom 1.8October 2026
Open support tickets38Down 14from 52This week
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

PropTypeDefaultWhat it does
labelReactNodenoneWhat the figure counts.
valuenumber | stringnoneA number is formatted; a string is shown as it is.
formatIntl.NumberFormatOptionsnoneHow the value and previous figure are formatted.
previousnumbernoneThe 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) => stringnoneFormats 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"noneWhich direction is good news. Decides the ok or danger tone; leave out for a neutral change.
unitReactNodenoneShown after the value in smaller type.
hintReactNodenoneA line under the figure, such as the period it covers.
localestring"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.
childrenReactNodenoneA visual on the right, usually a Sparkline.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-statdata-size is s, m or l.
body.eq-stat-bodyLabel, figure, change and hint, stacked.
label.eq-stat-label
figure.eq-stat-figureHolds the value and unit on one baseline.
value.eq-stat-value
unit.eq-stat-unit
delta.eq-stat-deltadata-tone is ok, danger or neutral; data-direction is up, down or flat.
arrow.eq-stat-arrow
change.eq-stat-changeThe signed figure, hidden from assistive tech.
spoken change.eq-stat-saidVisually 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index