Answer Metric Card
An in-chat metric card for a phone — headline, delta chip and a thumb-scrubbed sparkline, bar row or intensity strip whose readout is hoisted into the header, out from under the finger.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/answer-metric-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AnswerMetricCard" component — the metric
card an assistant attaches to an answer on a phone: a headline value, a delta
chip, and a series you read by dragging a thumb across it. React + lucide-react
only: no chart library, no gesture library, no animation library. The plot is
hand-drawn (one SVG path for the line variant, flex boxes for the other two)
because the whole component depends on knowing exactly where each sample sits.
Contract
- "use client". forwardRef<HTMLDivElement, AnswerMetricCardProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- Props:
- label: string — the metric name and the card's accessible name.
- points?: AnswerMetricPoint[] = [] — { label, value, display? }, oldest
first. `label` is the x-axis label AND what the readout says; `display` is a
pre-formatted value ("4m 12s", "1.2M") that wins over formatValue.
- value?: string | number — the headline at rest. A number goes through
formatValue; omitted, the newest point is used. It is deliberately allowed
to be a different figure from the series (a total or an average over the
same window) — that is what a summary usually is.
- caption?: string — the line under the headline at rest.
- delta?: number (signed percent) and deltaPolarity = "up-good" | "up-bad" |
"neutral". Polarity picks the tone, the sign only picks the arrow, so a
metric where falling is good flips one prop instead of the data.
- variant = "line" | "bars" | "cells" (default "line").
- status = "ready" | "loading" | "empty" | "unavailable" (default "ready"),
with note?: string carrying the one sentence that explains the last three —
including the assistant's own words for a refusal.
- activeIndex? / defaultActiveIndex = null / onActiveIndexChange? — the scrub
position, controlled and uncontrolled both supported. null means "parked on
the summary", NOT "index 0".
- scrubButtons = true — the prev / next pair under the plot.
- bleed = false — edge-to-edge inside its bubble.
- formatValue = Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).
- onRetry? — rendered as Try again on an unavailable card only.
- Pin the formatter's locale. A formatter that follows the host locale prints
one string on the server and another in the browser, and the card hydrates
with a mismatch on nothing but a number.
- Clamp everything that comes from a consumer through one helper that rejects
NaN, and coerce non-finite samples to 0 once, at the top: a single NaN would
poison the extent, the path and every bar height.
Behavior — a thumb where a cursor used to be
- There is no hover on a phone, so the value is not shown next to the finger; it
REPLACES the headline, and the point's label replaces the caption. That is the
whole idea: the readout lives above the finger, in the part of the card the
hand is not covering, and the rows keep their height so the card never grows
under the thumb mid-drag.
- Press: pointerdown reads the index under the finger, paints it immediately,
records { pointerId, startX, startY, previous } and takes setPointerCapture on
the plot — the element that started the gesture — so a finger that slides off
the card keeps driving the readout. Only the left button counts for a mouse.
- Axis claim: the plot keeps `touch-action: pan-y`. Under 8px of movement
nothing is decided. Past it, a mostly-vertical drag is the transcript being
scrolled: the gesture is handed back for good and the readout is restored to
where the press found it. A mostly-horizontal drag is claimed and scrubs.
preventDefault is never called anywhere in the component — touch-action states
the contract declaratively, which is also why no listener has to be registered
natively with { passive: false }.
- pointercancel (the browser deciding it owns the gesture) takes the same
restore path, so a scroll that happened to start on the chart leaves the card
exactly as it was.
- Sticky release: pointerup keeps the point. A touch readout that vanished when
the finger lifted would be a tooltip nobody can read. Going back to the
summary is deliberate: the Latest button in the header, or Esc.
- Index maths lives in two pure functions used by BOTH painting and hit-testing,
which is why the marker can never sit somewhere other than the value it reads:
a line owns both ends (i / (n - 1), nearest by rounding), bars and cells own a
slot each ((i + 0.5) / n, nearest by flooring the ratio).
- The drag record carries the last painted index and is written synchronously in
the handler, so a 120Hz drag re-renders once per slot crossed, not once per
frame.
- Every input writes the same index by one step rule: from rest the cursor lands
on the NEWEST point, and each press after that moves by one. Keyboard on the
plot: Right/Up +1, Left/Down -1, PageUp/PageDown a fifth of the series, Home
the oldest, End the newest, Esc back to the summary. Esc is only swallowed
when there is a readout to drop, so a card parked on its summary lets it
through to the sheet it is sitting in; the handled keys preventDefault so the
arrows do not also scroll the transcript.
- The prev / next buttons are the same step rule for a phone with no keyboard.
Because pressing them leaves focus on the button, the slider's aria-valuetext
is never announced — so they also write the readout into a polite live region.
The finger never does: a thumb crossing thirty slots would be a screen-reader
storm.
- Edge cases are first class, not guards bolted on: 0 points paints no plot,
1 point is painted but never scrubbable (a fact, not a series), an empty range
and a refusal are their own states with the model's sentence in `note`, and a
label longer than the bubble clamps to two lines rather than pushing the
number off a 390px screen.
- Cleanup: an unmount effect drops the drag record and releases the pointer
capture, so a gesture can never outlive the card driving it. There is no
timer, rAF, observer or media query to leak — reduced motion is handled in CSS.
Rendering & styling
- Semantic tokens only, monochrome first: rounded-2xl border bg-card p-4 for the
card, rounded-lg for inner blocks, rounded for chips (the 16/12/8 ladder).
Label 13px/600 clamped to two lines, headline 28px/600 tabular-nums with tight
tracking, caption and range 10-11px text-muted-foreground.
- The HIGHEST-PRIORITY mark INVERTS rather than taking a colour: the active bar
(and, at rest, the newest bar — the one the headline is quoting) is
bg-foreground; the rest are bg-muted-foreground/30. Cells carry magnitude as
ink — bg-foreground at an opacity of 0.14 + 0.86 * normalised — so an 11px slot
still reads. Colour is spent on exactly one thing: a delta moving in the
direction that hurts is text-destructive.
- Line variant: viewBox 0 0 100 40 with preserveAspectRatio="none" and
vector-effect="non-scaling-stroke", so the path stretches to any width while
the stroke stays 2px; 3 units of padding keep the extremes from being clipped.
The marker is NOT in the SVG — a dot inside a non-uniformly scaled viewBox
would be an ellipse — it is a percent-positioned span with a ring in the card
colour so it survives crossing its own line.
- Bars keep a true zero baseline while nothing is negative (100 beside 102 must
not read as empty beside full) and fall back to min..max framing when the
series goes negative, which is the only honest option without an axis. A 3%
floor keeps a zero sample visible as a zero rather than as a gap.
- Motion is decoration only: the marker eases between button and keyboard jumps
and is pinned to the finger while dragging, bars cross-fade their tone, and
every one of those carries motion-reduce:transition-none. The skeleton's pulse
is motion-reduce:animate-none. Nothing about the feature depends on it.
- Touch: the scrub strip is 68px tall (a 56px plot plus padding) and full width;
the step buttons are size-11; the Latest button is min-h-11 with negative
margins so a 44px target still reads as a small piece of text. Nothing is
hover-only.
- Accessibility: the root is role="group" + aria-labelledby the label, aria-busy
while loading. The scrub strip is role="slider", tabIndex 0, with
aria-valuemin 0, aria-valuemax n-1, aria-valuenow (the newest index while
parked) and aria-valuetext carrying "label, value" — the sentence a screen
reader should say, not a bare index. The plot itself is aria-hidden; a card
with fewer than two points is not a slider at all.
- bleed drops the side borders and the corner radius and grows the horizontal
padding to max(1rem, env(safe-area-inset-left / -right)), so a card that spans
the transcript still clears the notch in landscape.
Customization levers
- variant is the presentation axis and nothing else changes: "line" for a
continuous trend, "bars" for discrete counts that need a zero baseline,
"cells" when the slot is too narrow for height to mean anything (thirty days
in a bubble).
- Feel lives in two numbers: AXIS_LOCK_PX (8 — raise it to 12 inside a list that
scrolls fast, drop it to 5 for a card in a static sheet) and the 150ms marker
glide. VIEW_H, PAD and the h-14 plot height set the density.
- scrubButtons={false} for a bubble that must stay tiny — the keyboard path and
the tap remain. Keep them on for any surface that is finger-only.
- Swap the invert for bg-primary / text-primary-foreground if your brand cannot
carry a black bar; leave the destructive delta on its own token either way.
- value + caption are free text: pass a total with a "vs previous" caption, or
omit both and let the newest point be the headline.
- formatValue is where currency, units and compact notation belong; `display`
per point is the escape hatch for values a formatter cannot produce.
- Skin it without touching the logic: the root takes className through cn() and
carries data-status and data-variant.
- To add a second series, keep ONE slider and one index — draw the extra path
and extend aria-valuetext; two scrubbers on one card cannot be read with one
thumb.Concepts
- Readout out from under the thumb — a phone has no hover, so a cursor tooltip cannot exist and a value drawn next to the touch point is a value the hand is covering. The card answers by promoting the readout: the headline itself becomes the value under the finger and the caption becomes that point's label, both in the top third of the card where nothing is resting on them.
- Claim narrowly, and never
preventDefault— the plot keepstouch-action: pan-y, so the browser owns vertical scrolling from the first frame and the card only ever gets the horizontal drag. Past 8px the direction decides once and for good: mostly-vertical hands the gesture back and restores the readout the press moved, mostly-horizontal scrubs. Because the contract is declared in CSS, no listener has to be registered natively to cancel an event, and no passive listener is ever fought. - A touch readout is sticky, a tooltip is not — lifting the finger keeps the point. Hover vanishes because the cursor left; a thumb leaves because it is done reading, and taking the number away at that exact moment is the most common bug in touch charts. Going back to the summary is a deliberate act — the Latest button or Esc — and the button hands focus to the slider on its way out rather than dropping it on
<body>. - One index, three inputs — finger, arrow keys and the prev / next buttons all write the same position through one step rule (from rest the cursor lands on the newest point). The keyboard path exists because a slider must have one; the button path exists because most phones have no keyboard, and it also speaks into a polite live region, since pressing a button leaves focus on the button where
aria-valuetextwould never be announced. - Slot geometry shared by painting and hit-testing — one pair of pure functions maps index to x and x to index, with the line owning both ends and bars and cells owning a slot each. Painting and hit-testing calling the same maths is what guarantees the marker sits on the sample it is quoting, at any point count, at any width.
- Degenerate cases are states, not guards — one sample is painted and never made a slider (a fact is not a series), an empty range and a refusal each carry the assistant's own sentence, a label longer than the bubble clamps at two lines instead of pushing the number off a 390px screen, and
bleedgrows the padding toenv(safe-area-inset-left / -right)so a card that spans the transcript still clears the notch in landscape.
Phone Number Input
A mobile phone field that regroups digits as you type, picks the country from a bottom sheet, and normalises pasted international numbers into E.164.
Answer Highlight
The conclusion of an AI answer stated first — four monochrome priority levels across plain, rule, KPI and icon compositions, plus a rail that pins the verdict below the notch once the reader has thumbed past the card.