Monet
@etched/monet 0.0.1

Stat tile

Ported

A 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.

Profiles saved1,284Up 12% from last week
PendingDid not load
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.

Profiles saved1,2841,402 unique LinkedIn profiles
Pending37Waiting or collecting
Needs attention8152 failed · 21 uncertain · 8 cancelled
Awaiting profile resolution0Missing or unresolved LinkedIn identity
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.

Referrals214Up 12% from last weekThis week
Days to hire38Down 4 days from last quarterMedian, this quarter
Offers declined3Up 2 from last monthThis month
Open roles59No change from last weekAcross 6 teams
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.

Lots testedThe count did not load
Chips binned0This week
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".

Warm pathsLoadingThrough 412 employees
Needs reviewLoadingReferrals with no owner
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>
  );
}

Accessibility

  • With onClick the tile is a native button: Enter and Space press it. With render it is a link: Enter follows it.
  • A plain tile is a div, read as text in order. A button or link tile is named by all its text; aria-label gives a shorter name.
  • Screen readers hear change.label, such as "Up 12% from last week", in place of the arrow and figure. The icon is hidden.
  • A missing value reads "Not available". While loading, the tile is aria-busy and reads "Loading".
  • Write change.label in words, with the direction and the period. On a filter tile, set aria-pressed; on a link to the list that shows, aria-current.

API reference

PropTypeDefaultDescription
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.
detailReactNode–A line under the value, such as a breakdown or a share of the total.
changeStatTileChange–The change over time, after the value: an arrow and the change, colored by its tone.
iconReactNode–A decorative mark before the label, such as an icon or a color dot. Screen readers skip it.
loadingboolean–Shows a bar in place of the value and the change, and marks the tile busy. Screen readers read loadingLabel.
loadingLabelstring"Loading"What screen readers read in place of the value while it loads. Pass translated text.
onClickMouseEventHandler<HTMLElement>–Makes the whole tile a button that calls this, for example to filter the table below.
renderReactElement<{ 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.