Display

Metric Comparison

A period-over-period comparison table — this period, last period, absolute change and change %, with polarity-aware tone, sortable columns, expandable breakdowns and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDown, ArrowUp, ChartColumn, ChevronRight, ChevronsUpDown, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  MetricComparisonChildRow,
  MetricComparisonRow,
  MetricComparisonSort,
  MetricComparisonSortColumn,
  MetricComparisonStatus,
} from "./metric-comparison.contract"

const SECOND_MS = 1000

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/metric-comparison.json

Prompt

Build a React + TypeScript + Tailwind "MetricComparison" component with zod and
lucide-react. It is a period-over-period report table: one row per metric, one
column for each of the two periods, then the absolute change and the change %.

Contract
- A zod schema (`metricComparisonRowSchema` in a sibling contract file) is the
  single source of truth for one row: { id, label, current: number,
  previous?: number | null, unit?: "number" | "currency" | "percent" |
  "duration", currency?: string (ISO 4217), fractionDigits?: 0–4,
  higherIsBetter?: boolean, hint?: string, children?: childRow[] }. The child
  schema is the same shape minus `children` — a breakdown is one level deep,
  because this is a report, not a tree.
- `previous: null` / omitted means NO BASELINE EXISTS and is a different thing
  from `previous: 0`. Keep the two apart in the type; collapsing them is what
  produces a fabricated −100%.
- `unit` sits on the row, not the table: one report mixes dollars, counts,
  percentages and latencies. `percent` values are already in percentage points
  (4.2 → "4.2%"); `duration` values are milliseconds.
- `higherIsBetter` (default true) is the metric's POLARITY, not the arrow's
  direction. Cost, churn, latency and refunds pass false.
- A separate `metricComparisonStatusSchema` — "loading" | "empty" | "error" |
  "ready" — is the table's own render state.
- Component props: items; status; currentLabel / previousLabel (the two column
  headers, e.g. "This month" / "Last month"); title + description (rendered in
  the table's `<caption>`, which becomes its accessible name); locale (default
  "en-US"); sortable (default true); defaultSort ({ column, direction } | null,
  default null = the order the data layer sent); defaultExpandedIds;
  skeletonRows (clamped 1–24); onRetry; emptyState; className. Forward the ref
  and spread the rest onto the root.
- Nothing dead ever renders: no onRetry means the error branch has no button,
  sortable={false} means header cells are plain text, and a row without
  `children` gets no disclosure control.

Behavior — the change arithmetic (this is the component's whole point)
- One function computes the change and returns { delta, ratio, kind, direction,
  tone }, with four kinds because "previous" has four meanings:
  1. no baseline (null / non-finite) → kind "none": both change cells render an
     em dash. No arrow is drawn, and the cell carries aria-label="No comparison
     available" so a screen reader hears the reason instead of "dash".
  2. previous === 0 and current !== 0 → kind "new": ratio is UNDEFINED and is
     never computed, so Infinity% cannot exist. The change column still shows
     the absolute delta; the ratio column shows "New".
  3. delta === 0, including 0 → 0 → kind "flat": "No change", tone neutral.
     0 → 0 is unchanged, not new; 5 → 5 gets the same wording so the column
     reads consistently.
  4. otherwise → ratio = delta / Math.abs(previous). Dividing by the ABSOLUTE
     baseline is what makes a recovery from −18,400 to −4,200 read +77.2%
     instead of −77.2%.
- Tone comes from polarity, never from the sign: tone = (delta > 0) ===
  higherIsBetter ? positive : negative, and neutral when the delta is zero or
  missing. A churn rate rising 3.1% → 3.8% is negative; a support cost falling
  $5.30 → $4.12 is positive. The arrow always follows the sign, so direction
  and judgement are two separate channels.
- Formatting is per unit and per row, all through Intl.NumberFormat with an
  explicit locale: currency uses the row's ISO code (and its own decimals
  unless fractionDigits pins them); percent appends "%"; duration picks
  ms / s / min / h by magnitude. Deltas are formatted with
  signDisplay: "exceptZero", and the delta of a PERCENT metric is rendered in
  "pp", not "%" — 3.1% → 3.8% moved 0.7 pp. The ratio uses one decimal below
  100% and none above it. Cache formatters by (unit × currency × decimals ×
  signed) inside the memo that formats the rows; building one per cell is 120
  objects per keystroke on a 30-row table.
- Sorting is internal state over five columns (label / current / previous /
  delta / ratio). Clicking a header cycles lead direction → the other →
  none: numeric columns lead descending (biggest mover first), the name column
  leads ascending, and the third click restores the data layer's own order,
  which is itself information in a report. `aria-sort` on the `<th>` tracks it.
  Rows with no ratio (kind "none" / "new") sort last in BOTH directions, and a
  breakdown re-sorts inside its parent instead of escaping it.
- A parent row's disclosure control is a real `<button>` with aria-expanded and
  (while open) aria-controls listing the child row ids; collapsed children are
  unmounted rather than height-collapsed, because a zero-height row still holds
  tabbable content. The label lives on aria-label, not in an sr-only span:
  clipped text is still copied to the clipboard, and "Show breakdown of Total
  revenue" inside a row header is exactly what ruins a paste into a spreadsheet.
- Four first-class status branches: loading → a skeleton table with the real
  column geometry plus one sr-only role="status" (the table is aria-hidden and
  its header renders WITHOUT sort buttons, since a focusable control inside an
  aria-hidden subtree is an unnameable focus stop); empty → a replaceable
  zero-state; error → message + "Try again" when onRetry exists; ready → the
  table. Render every row you are handed — no MAX_ROWS, no fixed height with
  overflow hidden.

Rendering & styling
- A real `<table>`: `<caption>` for the title, `<th scope="col">` headers,
  `<th scope="row">` for each metric name. That is what makes the component
  printable and paste-able, and it is why the tone judgement rides on an
  `<svg aria-label>` (which contributes no text to the clipboard) instead of an
  sr-only span.
- Every figure is right-aligned, `tabular-nums` and `whitespace-nowrap`; a
  wrapped number is an unreadable number. Metric names use `break-words` (never
  `break-all`, which collapses the column's min-content width to one character).
- Semantic tokens only: bg-card / text-card-foreground (shell), border (rules),
  text-muted-foreground (previous column, hints, headers), bg-muted/30 (child
  rows), bg-muted/40 (row hover), text-destructive (negative tone),
  text-foreground (positive tone), focus-visible:ring-2 ring-ring. In a
  monochrome palette "good" is the plain strong foreground and only "bad"
  spends the chromatic token; both stay above 4.5:1 in light and dark.
- Width is handled with @container queries on the component, not viewport
  breakpoints, and no column is ever silently dropped: below @2xl the absolute
  Change column moves under the change %, below @lg the previous-period column
  moves under the current figure as "from X", gutters tighten and the type
  steps down to text-xs. The table sits in an overflow-x-auto wrapper, so the
  worst case scrolls inside the component instead of the page.
- Only the chevron rotation and the skeleton pulse animate, both with
  motion-reduce:*-none; nothing depends on them.

Customization levers
- Tone palette: TONE_CLASS maps positive/negative/neutral to class strings — if
  your palette has a success token, point positive at it; the polarity logic
  above it does not change.
- Columns: drop the absolute Change column entirely for a tighter report, or
  add a target/budget column by extending the row schema and the sort key union
  together.
- Breakpoints: @lg and @2xl are where the previous and change columns fold; move
  them if your rows carry longer labels or wider currencies.
- Density: px-2/py-2 with text-xs @lg:text-sm reads as a report; py-3 and
  text-sm throughout gives a roomier dashboard table.
- Units: the unit union is the extension point — add "bytes" or "ratio" by
  adding one branch to the figure formatter; everything else keys off it.
- Depth: children are deliberately one level. If you need a real tree, replace
  the flattening step with a recursive walk and add per-depth indentation, but
  keep the row headers in `<th scope="row">` or you lose the printable table.
- Sorting: pass defaultSort to open on "biggest mover first"
  ({ column: "ratio", direction: "desc" }), or sortable={false} for a fixed,
  print-shaped report.

Concepts

  • Polarity, not signhigherIsBetter decides the colour; the arrow decides the direction. They are separate channels because a falling cost and a falling revenue point the same way and mean opposite things. Flip one boolean and the whole row re-reads without touching a class name.
  • Four meanings of "previous" — missing, zero, equal and ordinary are four different rows, not one formula. Only the fourth one divides, which is why Infinity% and NaN% cannot appear; 0 → 0 says "No change" while 0 → 50 says "New".
  • Absolute denominator — the ratio divides by |previous|, so the sign of the percentage always matches the sign of the delta even when the baseline is negative (−18,400 → −4,200 reads +77.2%).
  • Percentage points — the difference between two percentages is measured in pp, not %. A churn rate moving 3.1% → 3.8% is "+0.7 pp" in the change column and "+22.6%" in the change-% column, and both numbers are true.
  • Fold, don't drop — narrowing the container moves the previous figure under the current one and the absolute change under the percentage; only one copy is in the layout at a time, so nothing is lost visually or announced twice.
  • Printable semantics — a real <caption>, <th scope="col"> and <th scope="row"> are what let this table be printed and pasted into a spreadsheet; that is also why the tone word lives on an <svg aria-label> and the disclosure label on aria-label — clipped sr-only text still lands in the clipboard.

On This Page