Stat tile
PortedA label, a large value and an optional detail line, on a card. The app lays tiles out in its own grid. With onClick the tile is a button; with render, a link. Then the whole tile takes the hover fill and the focus ring. A tile that is a filter shows its state: aria-pressed on a button tile, or aria-current on a link tile, gives it the selected edge. Its accessible name is its text, in order. Set aria-label for a shorter one.
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function Preview() {
return (
<div style={{ ...tiles, gridTemplateColumns: "repeat(2, minmax(0, 1fr))", inlineSize: "100%" }}>
<StatTile label="Profiles saved" value="1,284" change={{ value: "12%", direction: "up", tone: "good", label: "Up 12% from last week" }} />
<StatTile label="Pending" value={null} detail="Did not load" />
</div>
);
}Installation
import { StatTile } from "@etched/monet/stat-tile";Examples
In a grid
Orbit's profile enrichment. The app sets the grid; tiles in a row take the same height.
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function InAGrid() {
return (
<div style={tiles}>
<StatTile label="Profiles saved" value="1,284" detail="1,402 unique LinkedIn profiles" />
<StatTile label="Pending" value="37" detail="Waiting or collecting" />
<StatTile label="Needs attention" value="81" detail="52 failed · 21 uncertain · 8 cancelled" />
<StatTile label="Awaiting profile resolution" value="0" detail="Missing or unresolved LinkedIn identity" />
</div>
);
}Change over time
change puts an arrow and the change after the value. Its tone says if the change is good news, so a drop in days to hire is green. Screen readers read its label in place of the arrow. icon puts a mark before the label.
import { BriefcaseIcon, ClockIcon, UserPlusIcon, XCircleIcon } from "@phosphor-icons/react";
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function ChangeOverTime() {
return (
<div style={tiles}>
<StatTile
icon={<UserPlusIcon />}
label="Referrals"
value="214"
change={{ value: "12%", direction: "up", tone: "good", label: "Up 12% from last week" }}
detail="This week"
/>
<StatTile
icon={<ClockIcon />}
label="Days to hire"
value="38"
change={{ value: "4 days", direction: "down", tone: "good", label: "Down 4 days from last quarter" }}
detail="Median, this quarter"
/>
<StatTile
icon={<XCircleIcon />}
label="Offers declined"
value="3"
change={{ value: "2", direction: "up", tone: "bad", label: "Up 2 from last month" }}
detail="This month"
/>
<StatTile
icon={<BriefcaseIcon />}
label="Open roles"
value="59"
change={{ value: "0", direction: "flat", label: "No change from last week" }}
detail="Across 6 teams"
/>
</div>
);
}Not available
With no value, the tile shows a dash, and screen readers read "Not available". A count of 0 still shows 0.
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function NotAvailable() {
return (
<div style={tiles}>
<StatTile label="Lots tested" value={null} detail="The count did not load" />
<StatTile label="Chips binned" value={0} detail="This week" />
</div>
);
}Loading
loading shows a bar in place of the value and marks the tile busy. Screen readers read "Loading".
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function Loading() {
return (
<div style={tiles}>
<StatTile loading label="Warm paths" value="3,214" change={{ value: "8%", direction: "up", tone: "good", label: "Up 8% from last week" }} detail="Through 412 employees" />
<StatTile loading label="Needs review" value="86" detail="Referrals with no owner" />
</div>
);
}Filters
With onClick, the whole tile is a button, for example a filter for the table below it. Set aria-pressed on each: the filter that is on shows an ink edge. Press it again to show all. Orbit's referral approvals, with their color dots.
Showing all referrals
import { useState, type CSSProperties } from "react";
import { Text } from "@etched/monet/text";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
/** A color dot before a label, like Orbit's approval metrics. */
const dot = (color: string) => <span style={{ inlineSize: "0.5rem", blockSize: "0.5rem", borderRadius: "50%", background: color }} />;
const decisions = [
{ key: "accepted", label: "Accepted", count: 412, color: "var(--et-tone-green-ink)" },
{ key: "passed", label: "Passed", count: 388, color: "var(--et-tone-red-ink)" },
{ key: "waiting", label: "Not yet approved", count: 204, color: "var(--et-tone-amber-ink)" },
];
const total = decisions.reduce((sum, { count }) => sum + count, 0);
export function Filters() {
const [filter, setFilter] = useState("");
return (
<div style={{ display: "grid", gap: "var(--et-space-3)" }}>
<div style={tiles}>
{decisions.map(({ key, label, count, color }) => (
<StatTile
key={key}
icon={dot(color)}
label={label}
value={count.toLocaleString()}
detail={`${Math.round((count / total) * 100)}% of ${total.toLocaleString()} referrals`}
aria-pressed={filter === label}
onClick={() => setFilter(filter === label ? "" : label)}
/>
))}
</div>
<Text variant="secondary" role="status">
{filter ? `Showing: ${filter}` : "Showing all referrals"}
</Text>
</div>
);
}Links
With render, the whole tile is a link (or a router link) to the filtered list. The link to the list you are on has aria-current, as a router's active link sets it, and shows the ink edge. Tracer's role summary.
import { type CSSProperties } from "react";
import { StatTile } from "@etched/monet/stat-tile";
/** The app's grid: as many 12 rem columns as fit. Monet does not lay tiles out. */
const tiles: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(12rem, 1fr))", gap: "var(--et-space-4)" };
export function Links() {
return (
<div style={tiles}>
<StatTile render={<a href="#interviewing" aria-current="page" />} label="Interviewing" value="12" detail="Avg 9 days in stage" />
<StatTile render={<a href="#offer" />} label="Offer" value="3" detail="Avg 4 days in stage" />
<StatTile render={<a href="#hired" />} label="Hired" value="2" detail="This quarter" />
</div>
);
}Accessibility
- With
onClickthe tile is a native button:EnterandSpacepress it. Withrenderit is a link:Enterfollows it. - A plain tile is a
div, read as text in order. A button or link tile is named by all its text;aria-labelgives a shorter name. - Screen readers hear
change.label, such as "Up 12% from last week", in place of the arrow and figure. Theiconis hidden. - A missing value reads "Not available". While
loading, the tile isaria-busyand reads "Loading". - Write
change.labelin words, with the direction and the period. On a filter tile, setaria-pressed; on a link to the list that shows,aria-current.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | ReactNode | – | What the value counts, for example "Profiles saved". |
| value * | ReactNode | – | The value, formatted by the app, for example count.toLocaleString(). Figures are tabular. null, undefined or "" shows "—", which screen readers read as "Not available". 0 shows 0. |
| detail | ReactNode | – | A line under the value, such as a breakdown or a share of the total. |
| change | StatTileChange | – | The change over time, after the value: an arrow and the change, colored by its tone. |
| icon | ReactNode | – | A decorative mark before the label, such as an icon or a color dot. Screen readers skip it. |
| loading | boolean | – | Shows a bar in place of the value and the change, and marks the tile busy. Screen readers read loadingLabel. |
| loadingLabel | string | "Loading" | What screen readers read in place of the value while it loads. Pass translated text. |
| onClick | MouseEventHandler<HTMLElement> | – | Makes the whole tile a button that calls this, for example to filter the table below. |
| render | ReactElement<{ className?: string }> | – | Makes the whole tile another element with the tile's look, such as a link: render={<a href="/candidates?status=offer" />} or a router link. |