Metric tile
One number for the period, how it moved against the previous period, its target, and a small chart of the period. The Dashboard's headline grid is six of them.
Playground
Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.
CVs sent
48Up 12%
Target 45
import { MetricTile } from '@/ui/metric-tile';<MetricTile label="CVs sent" value="48" change="12%" direction="up" target="Target 45" />Default
The label (a link to the records behind the number), the figure in tabular digits, the change against the previous period, the target and the period's chart. A fall is neutral, never red, and Behind target is a neutral pill.
CVs sent
48Up 12%
Target 45
Placements
3Down 25%
Target 5Behind target
import { LineChart } from '@/ui/chart';
import { MetricTile } from '@/ui/metric-tile';
const days = ['1 Oct', '3 Oct', '5 Oct', '7 Oct', '9 Oct', '11 Oct', '13 Oct', '15 Oct'];
const series = (values: number[]) => values.map((value, i) => ({ label: days[i]!, value, text: String(value) }));
export default function MetricTileDefault() {
return (
<div className="grid w-full grid-cols-1 gap-300 sm:grid-cols-2">
<MetricTile
label="CVs sent"
href="#cvs-sent"
source="CVs sent to clients in this period, from each Application's CV sent date"
value="48"
change="12%" direction="up"
target="Target 45"
chart={<LineChart size="compact" name="CVs sent" current={series([2, 4, 3, 6, 5, 8, 7, 11])} previous={series([1, 3, 4, 3, 5, 4, 6, 5])} />}
/>
<MetricTile
label="Placements"
href="#placements"
value="3"
change="25%" direction="down"
target="Target 5"
behind
chart={<LineChart size="compact" name="Placements" current={series([0, 0, 1, 1, 2, 2, 3, 3])} previous={series([0, 1, 1, 2, 2, 3, 3, 4])} />}
/>
</div>
);
}Large and small figure
Large for the one number a card is about. Money goes in money: written in the reader's number format with its ISO code and no symbol, and never added across currencies, so a second currency goes in the note. Small when a card holds several figures side by side: pass them as Metric size="small" children.
Invoiced fee
148,500 GBPNo change 0%
Also 12,000 USD. The chart shows GBP.
Placed fee
96,000 GBP
Overdue now
18,200 GBP
2 invoices
import { Metric, MetricTile } from '@/ui/metric-tile';
// Money keeps its code and is never added across currencies: the lead currency is the figure, the rest go in the note.
export default function MetricTileSizes() {
return (
<div className="grid w-full grid-cols-1 items-start gap-300 sm:grid-cols-2">
<MetricTile
label="Invoiced fee"
locale="en-GB"
money={[
{ amount: 148500, currency: 'GBP' },
{ amount: 12000, currency: 'USD' },
]}
change="0%"
note="The chart shows GBP."
/>
<MetricTile>
<Metric size="small" label="Placed fee" locale="en-GB" money={[{ amount: 96000, currency: 'GBP' }]} />
<Metric size="small" label="Overdue now" locale="en-GB" money={[{ amount: 18200, currency: 'GBP' }]} note="2 invoices" />
</MetricTile>
</div>
);
}States
Hover or focus the Offers label: ink and an underline, and the card itself doesn't move. Loading draws skeleton bars at the label and figure size; press Replay. Empty keeps zero as a figure and the chart gives way to one line.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { LineChart } from '@/ui/chart';
import { MetricTile } from '@/ui/metric-tile';
// Press Replay: skeleton bars at the label and figure size while the numbers load.
export default function MetricTileStates() {
const [loading, setLoading] = useState(true);
const replay = () => {
setLoading(true);
setTimeout(() => setLoading(false), 1500);
};
return (
<div className="flex w-full flex-col items-start gap-200">
<div className="grid w-full grid-cols-1 items-start gap-300 sm:grid-cols-3">
<MetricTile label="Offers" href="#offers" value="6" change="2" direction="up" />
<MetricTile label="Interviews" loading={loading} value="14" change="3" direction="up" />
<MetricTile
label="Collected fee"
value="£0"
chart={<LineChart size="compact" name="Collected fee" empty="Nothing collected in this period" current={[{ label: '1 Oct', value: 0, text: '£0' }, { label: '15 Oct', value: 0, text: '£0' }]} />}
/>
</div>
<Button size="sm" onClick={replay}>
Replay
</Button>
</div>
);
}Edge cases
A large amount keeps every digit unless the tile is short. A figure with nothing behind it is an em dash, never NaN or null. A long or non-Latin label wraps; a large change stays as written.
Pipeline value
2.450.000.000 VND
Pipeline value, short
2,5 T VND
Conversion
No figure
No CVs sent in this period
Placements by Nguyễn Thị Minh Khai and Zoë O'Brien-Smith
1,234,567Up 99+
import { MetricTile } from '@/ui/metric-tile';
// A huge figure keeps every digit unless the tile says short; a missing figure is an em dash, never NaN; long and
// non-Latin labels wrap.
export default function MetricTileEdgeCases() {
return (
<div className="grid w-full grid-cols-1 items-start gap-300 sm:grid-cols-2 lg:grid-cols-4">
<MetricTile label="Pipeline value" locale="vi-VN" money={[{ amount: 2450000000, currency: 'VND' }]} />
<MetricTile label="Pipeline value, short" locale="vi-VN" short money={[{ amount: 2450000000, currency: 'VND' }]} />
<MetricTile label="Conversion" value={Number.NaN} note="No CVs sent in this period" />
<MetricTile label="Placements by Nguyễn Thị Minh Khai and Zoë O'Brien-Smith" value="1,234,567" change="99+" direction="up" />
</div>
);
}Lozenge
A status: where a record stands right now. Stages, Job status, Do not contact, meeting status. Soft fill with dark text of the same hue, so it reads at a glance without shouting.
Record chip
Every reference to a record, in a cell, the details rail, a card or Flo's text. The mark says what the record is; the name says which one.