Stat tile
Data displayA metric, its change and an optional sparkline — with the direction written in the words rather than left to an arrow and a colour.
Preview
Booked revenue
£48,200
Up 12% on last quarter
Controls
Notes
The change reads "Up 12% on last quarter" and the arrow is decorative. An arrow plus a colour is exactly the failure SC 1.4.1 describes: nothing for a screen reader, and nothing for a reader who cannot separate the two colours.
Direction is not sentiment. Churn going up is not good news, so the accent is opt-in through a separate emphasis flag rather than wired to "up".
The number is tabular-nums, so a row of these does not jitter as the digits change.
The sparkline is aria-hidden and has no accessible values. It must never carry information the number and the change do not already state.
Source
ShowHide
import { cn } from "@/lib/cn";
/**
* A metric tile.
*
* The direction is in the WORD — "Up 12% on last quarter" — and the arrow is
* decoration. An arrow plus a colour is exactly the failure SC 1.4.1 describes:
* nothing for a screen reader, and nothing for the readers who cannot tell the
* two colours apart.
*
* Direction is also not sentiment. Churn going up is not good news, so the
* accent is opt-in through `emphasis` rather than wired to "up".
*/
const directions = {
up: { glyph: "↑", word: "Up", points: "0,26 14,21 28,23 42,15 56,17 70,9 84,11 98,3" },
down: { glyph: "↓", word: "Down", points: "0,4 14,9 28,7 42,15 56,13 70,21 84,19 98,27" },
flat: { glyph: "→", word: "Flat", points: "0,16 14,14 28,17 42,15 56,16 70,14 84,16 98,15" },
} as const;
export type StatDirection = keyof typeof directions;
export function Stat({
label,
value,
delta,
direction = "up",
emphasis = false,
spark = true,
className,
}: {
label: string;
value: string;
/** The change, without its direction — "12% on last quarter". */
delta?: string;
direction?: StatDirection;
/** Accent the change. Reserve it for "look at this", not for "the number rose". */
emphasis?: boolean;
spark?: boolean;
className?: string;
}) {
const trend = directions[direction];
return (
<div
className={cn(
"flex flex-col rounded-xl border border-rule bg-paper-raised p-6",
className,
)}
>
<p className="eyebrow">{label}</p>
{/* tabular-nums so a column of these does not jitter as digits change. */}
<p className="mt-4 text-4xl leading-none font-medium tabular-nums text-ink">{value}</p>
{delta && (
<p className={cn("mt-3 text-[13px]", emphasis ? "text-accent" : "text-ink-muted")}>
<span aria-hidden="true">{trend.glyph} </span>
{trend.word} {delta}
</p>
)}
{spark && (
// Decoration with no accessible values. It must never carry something
// the number and the delta do not already say.
<svg
viewBox="0 0 98 30"
aria-hidden="true"
preserveAspectRatio="none"
className={cn("mt-5 h-8 w-full", emphasis ? "text-accent" : "text-ink-faint")}
>
<polyline
points={trend.points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
)}
</div>
);
}
export default function Demo({
label = "Booked revenue",
value = "£48,200",
delta = "12% on last quarter",
direction = "up",
emphasis = false,
spark = true,
}: {
label?: string;
value?: string;
delta?: string;
direction?: StatDirection;
emphasis?: boolean;
spark?: boolean;
}) {
return (
<Stat
className="w-full max-w-[16rem]"
label={label}
value={value}
delta={delta}
direction={direction}
emphasis={emphasis}
spark={spark}
/>
);
}
Next
ProgressNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.