Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, CircleSlash, Minus } from "lucide-react"
import { cn } from "@/lib/utils"

/** One sample of the series. `label` is what the readout says while scrubbing. */
export interface AnswerMetricPoint {
  /** Short x-axis label — "Mar 12", "Mon", "Week 4". Read out with the value. */
  label: string
  value: number
  /** Pre-formatted value ("4m 12s", "1.2M"). Wins over formatValue(value). */
  display?: string
}

Installation

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

Prompt

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 keeps touch-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-valuetext would 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 bleed grows the padding to env(safe-area-inset-left / -right) so a card that spans the transcript still clears the notch in landscape.

On This Page