AI

Confidence Meter

A response-confidence reading — segmented bar or small ring banded by your own cut points, with a 'why' disclosure that breaks the score into per-signal factor rows.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Confidence Meter
 *
 * "How sure is the model about this answer?" rendered as a segmented bar or a
 * small ring, plus an optional disclosure that breaks the reading down into the
 * factors it came from (retrieval coverage, self-consistency, source
 * agreement…).
 *
 * Three rules drive every decision below:

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/confidence-meter.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ConfidenceMeter" component: a compact,
purely presentational reading of how sure a model is about one answer, plus an
optional disclosure that breaks the reading down into the signals it came from.
No charting library — a flex track and one SVG circle are the whole drawing.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children; the root spreads the rest.
- score?: number — 0–1. Clamped; undefined / NaN / Infinity all mean "no
  number". This is the ONLY source of a printed percentage.
- level?: "low" | "medium" | "high" — explicit band. Derived from `score` when
  omitted. When BOTH are given the band wins for colour and wording while the
  number still drives the fill, so a server-side classification stays
  authoritative over a client-side cut point.
- thresholds?: { medium: number; high: number } (default { medium: 0.45,
  high: 0.75 }) — half-open cut points: >= high is high, >= medium is medium,
  else low. Clamp both to 0–1 and SWAP them if the caller passes them inverted;
  never render a meter that reports nonsense.
- variant?: "segments" | "ring" (default "segments").
- size?: "sm" | "md" | "lg" (default "md") — segments: track height + width;
  ring: outer diameter; both: root text size.
- segments?: number (default 5) — cells in the segmented track, clamped to
  1–12, and only honoured when there is a score to interpolate (see below).
  Ignored by the ring.
- label?: string — overrides the band's default wording.
- unknownLabel?: string (default "Confidence unavailable").
- showLabel?: boolean (default true), showScore?: boolean (default true).
- factors?: readonly { label: string; score?: number; detail?: string }[] —
  rows for the disclosure, in the order given (sort before passing). A factor
  with no score renders as "not measured", not as 0%.
- whyLabel?: string (default "Why?"), whyTitle?: string (default "Why this
  reading"), summary?: string, footnote?: string.
- showBandScale?: boolean (default true), onWhyOpenChange?: (open) => void.
- animate?: boolean (default true).
- Export the level, variant, size, thresholds and factor types.

Behavior — three precision modes, and the geometry says which one you are in
- QUANTITATIVE (a finite score): the track fills continuously — the boundary
  cell is partially filled, the ring draws a continuous arc — and the
  percentage is printed.
- QUALITATIVE (a level with no score): there is nothing to interpolate, so the
  track collapses to exactly THREE units, one per band, and fills whole units;
  the ring becomes three arc chunks separated by gaps. `segments` is ignored
  and NO percentage is printed. The reader must be able to tell a measured 62%
  from a bucketed "medium" without reading the props.
- UNKNOWN (neither): render an empty track and say "Confidence unavailable" —
  do not draw a zeroed bar, because an absent estimate is not a low estimate.
- Rounding must never manufacture certainty: a score below 1.0 prints at most
  99%, a score above 0 prints at least 1%, and aria-valuenow uses the same
  guarded number as the visible text. Only exact 0 and exact 1 get 0% / 100%.

Behavior — accessibility (this is where most confidence widgets go wrong)
- The gauge, the label and the number are ONE node: role="meter" with
  aria-label (the metric name, default "Response confidence"), aria-valuemin=0,
  aria-valuemax=100, aria-valuenow and aria-valuetext ("Medium confidence,
  62%"). With no number the same node is role="img" with the whole reading in
  its accessible name — a meter without a value would be a lie.
- Both roles are children-presentational, which is exactly why they are used:
  the visible label inside is not announced a second time.
- Therefore the "Why?" trigger MUST be a sibling of that node, never inside it:
  an interactive element inside a children-presentational role disappears from
  assistive tech.
- No live region anywhere. A confidence reading is a standing measurement, not
  an event; re-announcing it on every re-render would talk over the answer.
- Expose data-level ("low" | "medium" | "high" | "unknown") and data-variant on
  the root, plus data-slot hooks on the track, the label and the trigger.

Behavior — the "Why?" disclosure (hand-rolled, not a popover library)
- Render the trigger only when factors.length > 0: an empty disclosure is worse
  than none.
- Trigger: <button type=button> with aria-expanded, aria-controls (only while
  open), aria-haspopup="dialog", and an accessible name that starts with the
  visible label and continues with the panel title, so voice control still
  hears "Why?".
- Panel: role="dialog" + aria-labelledby, tabIndex={-1}, focused on open, NOT
  modal — nothing is trapped and the page keeps scrolling.
- Placement is measured, not guessed: before paint, compare the trigger rect
  against the viewport and flip above/below and start/end. Re-measure on resize
  and on scroll IN THE CAPTURE PHASE, because this meter usually sits in the
  footer of a scrollable answer panel.
- Three dismissal paths: Escape (and return focus to the trigger — an explicit
  "take me back"), pointerdown outside the wrapper (capture phase, so a
  consumer's stopPropagation cannot strand the panel), and focus leaving the
  subtree via Tab (close, but leave focus where the user sent it).
- Every document listener is attached under `if (!open) return` inside an
  effect whose cleanup removes it, so unmounting mid-open leaves nothing behind.
- Read onOpenChange through a ref so close() stays referentially stable and the
  listeners are attached exactly once per open.
- Panel content: title, one honest sentence for the current band, the factor
  rows (label + own percentage + a decorative mini-bar banded by the SAME cut
  points, so the reader sees which signal dragged the reading down, plus an
  optional detail line), the band scale ("Bands · low < 45% · medium 45–75% ·
  high >= 75%") ONLY when a number was actually banded, and a closing footnote.
- Give the row list role="list" — Safari drops list semantics when the bullets
  are styled off.

Behavior — motion
- On mount the fill grows from empty: keep a `revealed` flag false for two
  animation frames (one to commit the empty width, one to flip), then transition
  width / stroke-dashoffset. Cancel both frames on unmount.
- Qualitative arc chunks fade in with a small per-chunk delay instead of
  sliding, because they are discrete units, not an interpolation.
- Under prefers-reduced-motion (matchMedia via useSyncExternalStore) the
  component is BORN at its final value: no empty first frame, no transition.
  Settle that flip during render, not in an effect, so it never paints a frame
  it will not animate.

Copy rules (as important as the code)
- Default strings talk about evidence and agreement, never about being right:
  "The sources agree with each other and the answer stayed stable across
  samples. High agreement is not proof — spot-check anything you will act on."
- Low confidence is not an error: word it as thin or conflicting evidence, and
  never colour it with the destructive token.
- Footnote, always present: "Confidence describes how well the retrieved
  evidence and the model's own samples agree — not whether the answer is true."

Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch(): bands map to chart tokens —
  high = chart-2, medium = chart-3, low = chart-5 (bg-*, text-*, stroke-*), the
  empty track is bg-muted / stroke-muted, the unknown fill is
  bg-muted-foreground/40, and the panel is bg-popover / text-popover-foreground
  / border / shadow-md with focus-visible:ring-ring.
- Merge every className through cn(); the root is inline-flex so the meter can
  sit in a caption row next to other footer text.
- Ring: one SVG with pathLength={100} on the circles, so every dash number is a
  percentage and the geometry is independent of the radius; rotate the group
  -90° to start at twelve o'clock; strokeLinecap="round". At size "sm" the
  ring is too small for two digits, so the number moves next to the label.
- Numbers use tabular-nums so a changing reading does not jitter.
- Panel keyframes ship in a React 19 hoisted <style href precedence> and are
  disabled with motion-reduce:[animation:none].

Customization levers
- Bands: remap high/medium/low to any chart token (or to your own
  --confidence-* variables) in the three lookup maps; keep low OFF the
  destructive token unless low confidence really is a failure in your product.
- Cut points: `thresholds` is per instance, so a compliance surface can demand
  { medium: 0.6, high: 0.9 } while a chat footer keeps the defaults; the panel
  prints whatever cut points were used.
- Resolution: `segments` 3–12 for a coarse or fine track; switch to variant
  "ring" for table rows and dense toolbars; `size` scales the track, the ring
  and the text together.
- Density: drop showLabel for a gauge-only chip (the reading stays in the
  accessible name), drop showScore to publish bands without numbers, drop
  `factors` to remove the disclosure entirely.
- Wording: label / unknownLabel / summary / footnote / whyTitle / whyLabel are
  all overridable — localise them, but keep the honesty framing.
- Extra rows: `factors` is a plain array, so add cost, latency or reviewer
  sign-off as rows; a row with no score is a first-class "not measured".
- Analytics: onWhyOpenChange is where "did anyone check the breakdown?" goes.
- Layout hooks: data-slot="confidence-track" / "confidence-label" /
  "confidence-why" let a parent widen or restyle the parts without forking.

Concepts

  • Quantitative vs qualitative fill — a real score fills the track continuously and prints a percentage; a bare band collapses the track to one unit per band and prints nothing, so the geometry itself tells the reader how much precision is behind the reading.
  • Unknown is a state, not a zero — when neither a score nor a band arrives, the meter says "Confidence unavailable" instead of drawing an empty bar; a missing estimate and a bad estimate mean opposite things to the person deciding whether to trust the answer.
  • No-false-certainty rounding — 0.998 prints 99% and 0.0004 prints 1%, in the visible text and in aria-valuenow alike; only the exact endpoints are allowed to read as absolute.
  • One reading, announced once — the gauge, the label and the number live in a single role="meter" (or role="img") node whose subtree is presentational, which is also why the disclosure trigger sits outside it: an interactive element inside a children-presentational role vanishes from assistive tech.
  • Factor breakdown — the disclosure turns one number back into the signals that produced it (retrieval coverage, self-consistency, source agreement), each banded by the same cut points, so the reader can see which signal dragged the reading down instead of arguing with a bar.
  • Threshold-driven band — the colour and the wording come from caller-supplied cut points, not from the component's taste; the panel prints the cut points that were used, and only when a number was actually banded by them.

On This Page