Data
Stat group
A row of Stats with dividers between them that wraps into a grid as it narrows. Plain or as a card.
- Built on
CSS grid auto-fitoverflow: clip- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.3 kB
- Built from
- Stat
Active users12,480
Churn2.1%
MRR€48.2k
Try its options
Loading its options.
Code
import { Stat } from "@/components/equip/stat";
import { StatGroup } from "@/components/equip/stat-group";
export default function Demo() {
return (
<StatGroup label="This month" size="s" style={{ width: 208 }}>
<Stat label="Active users" value={12480} />
<Stat label="Churn" value="2.1%" />
<Stat label="MRR" value="€48.2k" />
</StatGroup>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div role="group" aria-label="This month" style="width:208px" class="eq-stat-group" data-variant="plain" data-orientation="horizontal" data-size="s">
<div class="eq-stat-group-grid">
<div class="eq-stat" data-size="s">
<div class="eq-stat-body"><span class="eq-stat-label">Active users</span><span class="eq-stat-figure"><span class="eq-stat-value">12,480</span></span></div>
</div>
<div class="eq-stat" data-size="s">
<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></div>
</div>
<div class="eq-stat" data-size="s">
<div class="eq-stat-body"><span class="eq-stat-label">MRR</span><span class="eq-stat-figure"><span class="eq-stat-value">€48.2k</span></span></div>
</div>
</div>
</div>Examples
Four stats with changes
Columns are at least 160px, so the row wraps into a grid as it narrows and the dividers follow: a line only ever sits between two stats.
Monthly recurring revenue$128,400Up 8.9%from $117,900
Paying teams1,842Up 2.9%from 1,790
Churn2.1%Up 16.7%from 1.8%
Refunds14No changefrom 14
Code
import { Stat } from "@/components/equip/stat";
import { StatGroup } from "@/components/equip/stat-group";
export default function Example() {
return (
<StatGroup label="Billing, October 2026" style={{ width: "100%", maxWidth: 760 }}>
<Stat
label="Monthly recurring revenue"
value={128400}
previous={117900}
goodWhen="up"
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>
<Stat label="Paying teams" value={1842} previous={1790} goodWhen="up" />
<Stat label="Churn" value={0.021} previous={0.018} goodWhen="down" format={{ style: "percent", maximumFractionDigits: 1 }} />
<Stat label="Refunds" value={14} previous={14} goodWhen="down" />
</StatGroup>
);
}HTMLworks in any framework, with the part's CSS
<div role="group" aria-label="Billing, October 2026" style="width:100%;max-width:760px" class="eq-stat-group" data-variant="plain" data-orientation="horizontal" data-size="m">
<div class="eq-stat-group-grid">
<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">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="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">Refunds</span><span class="eq-stat-figure"><span class="eq-stat-value">14</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 14</span></span></div>
</div>
</div>
</div>Bordered card
Requests2.3MPeak at 14:00
p95 latency182msDown 24.2%from 240
Error rate0.42%Under the 1% budget
Code
import { Stat } from "@/components/equip/stat";
import { StatGroup } from "@/components/equip/stat-group";
export default function Example() {
return (
<StatGroup label="eu-west-1, last 24 hours" variant="card" style={{ width: "100%", maxWidth: 560 }}>
<Stat label="Requests" value={2310000} format={{ notation: "compact", maximumFractionDigits: 1 }} hint="Peak at 14:00" />
<Stat label="p95 latency" value={182} unit="ms" previous={240} goodWhen="down" />
<Stat label="Error rate" value={0.0042} format={{ style: "percent", maximumFractionDigits: 2 }} hint="Under the 1% budget" />
</StatGroup>
);
}HTMLworks in any framework, with the part's CSS
<div role="group" aria-label="eu-west-1, last 24 hours" style="width:100%;max-width:560px" class="eq-stat-group" data-variant="card" data-orientation="horizontal" data-size="m">
<div class="eq-stat-group-grid">
<div class="eq-stat" data-size="m">
<div class="eq-stat-body"><span class="eq-stat-label">Requests</span><span class="eq-stat-figure"><span class="eq-stat-value">2.3M</span></span><span class="eq-stat-hint">Peak at 14:00</span></div>
</div>
<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">Error rate</span><span class="eq-stat-figure"><span class="eq-stat-value">0.42%</span></span><span class="eq-stat-hint">Under the 1% budget</span></div>
</div>
</div>
</div>Vertical, for a sidebar
Vertical keeps one stat per row whatever the width, with a line between each.
Open issues37Down 11.9%from 42
Merged this week18Up 50%from 12
Median review time5h 20m
Code
import { Stat } from "@/components/equip/stat";
import { StatGroup } from "@/components/equip/stat-group";
export default function Example() {
return (
<StatGroup label="Atlas project" orientation="vertical" size="s" variant="card" style={{ width: "100%", maxWidth: 220 }}>
<Stat label="Open issues" value={37} previous={42} goodWhen="down" />
<Stat label="Merged this week" value={18} previous={12} goodWhen="up" />
<Stat label="Median review time" value="5h 20m" />
</StatGroup>
);
}HTMLworks in any framework, with the part's CSS
<div role="group" aria-label="Atlas project" style="width:100%;max-width:220px" class="eq-stat-group" data-variant="card" data-orientation="vertical" data-size="s">
<div class="eq-stat-group-grid">
<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="s">
<div class="eq-stat-body"><span class="eq-stat-label">Merged this week</span><span class="eq-stat-figure"><span class="eq-stat-value">18</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">+50%</span><span class="eq-stat-said">Up 50%</span><span class="eq-stat-previous">from 12</span></span></div>
</div>
<div class="eq-stat" data-size="s">
<div class="eq-stat-body"><span class="eq-stat-label">Median review time</span><span class="eq-stat-figure"><span class="eq-stat-value">5h 20m</span></span></div>
</div>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the group for screen readers and adds role="group". |
variant | "plain" | "card" | "plain" | Plain sits on the page; card draws a border round the group. |
orientation | "horizontal" | "vertical" | "horizontal" | Horizontal flows into as many columns as fit; vertical stacks one per row. |
size | "s" | "m" | "m" | s is compact: columns from 56px instead of 160px, and each Stat that sets no size of its own gets size s. |
children | ReactNode | none | Stat elements. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on the root. |
Anatomy
| root | .eq-stat-group | Variant, orientation and size are on data attributes. |
|---|---|---|
| grid | .eq-stat-group-grid | The auto-fit grid. Each item draws its dividers, and the grid clips the ones on its outer edges. |
Notes
- A line only ever shows between two stats, wherever the grid wraps, because the lines on the outer edges are clipped.
- Stat is a div, not a description list, so the group is a role="group" with your label rather than a dl.
- Columns never get narrower than 160px, or 56px at size s, nor wider than the group, so nothing overflows at 320px.
- The group passes its size to each Stat child that does not set one, so a Stat may still choose its own; a Stat wrapped in another component does not receive it.
- Stats in a row stretch to the same height and align at the top, so the dividers in a row are all one length.
- 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-group.tsx45 lines
import { Children, cloneElement, isValidElement, type ComponentProps } from "react";
import { Stat, type StatProps } from "./stat";
import "./stat-group.css";
export type StatGroupProps = ComponentProps<"div"> & {
/** Names the group for screen readers, such as "Billing, October". Adds role="group". */
label?: string;
/** Plain sits on the page; card draws a border round the group. */
variant?: "plain" | "card";
/** horizontal flows into as many columns as fit; vertical stacks one per row. */
orientation?: "horizontal" | "vertical";
/** s is compact, for a sidebar or a dense card: narrower columns, and passed to each Stat as its size. */
size?: "s" | "m";
};
export function StatGroup({
label,
variant = "plain",
orientation = "horizontal",
size = "m",
className,
children,
...rest
}: StatGroupProps) {
return (
<div
role={label ? "group" : undefined}
aria-label={label}
{...rest}
className={["eq-stat-group", className].filter(Boolean).join(" ")}
data-variant={variant}
data-orientation={orientation}
data-size={size}
>
<div className="eq-stat-group-grid">
{/* Each Stat takes the group's size, unless it sets its own. */}
{Children.map(children, (child) =>
isValidElement<StatProps>(child) && child.type === Stat && child.props.size === undefined
? cloneElement(child, { size })
: child,
)}
</div>
</div>
);
}components/equip/stat-group.css65 lines
@layer equip {
.eq-stat-group {
--_gap: var(--eq-space-5);
--_min: 160px;
display: block;
min-width: 0;
}
.eq-stat-group[data-size="s"] {
--_gap: var(--eq-space-3);
--_min: 56px;
}
.eq-stat-group[data-variant="card"] {
padding: var(--eq-space-4);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
}
.eq-stat-group[data-variant="card"][data-size="s"] {
padding: var(--eq-space-3);
}
/* As many columns as fit, never narrower than --_min and never wider than the group. */
.eq-stat-group-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--_min)), 1fr));
gap: var(--_gap);
/* Clips the dividers that fall outside the first column and above the first row, so a
line only ever shows between two stats, however the grid wraps. No padding here. */
overflow: clip;
}
.eq-stat-group[data-orientation="vertical"] .eq-stat-group-grid {
grid-template-columns: minmax(0, 1fr);
}
/* Items stretch to the row, so every divider in a row is the same length. */
.eq-stat-group-grid > * {
position: relative;
min-width: 0;
}
/* Every item draws a line in the middle of the gap before it and above it. */
.eq-stat-group-grid > *::before,
.eq-stat-group-grid > *::after {
content: "";
position: absolute;
background: var(--eq-line);
pointer-events: none;
}
.eq-stat-group-grid > *::before {
inset-block: 0;
inset-inline-start: calc(var(--_gap) / -2 - 0.5px);
width: 1px;
}
.eq-stat-group-grid > *::after {
inset-inline: calc(var(--_gap) / -2) calc(var(--_gap) / -2);
top: calc(var(--_gap) / -2 - 0.5px);
height: 1px;
}
}