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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/confidence-meter.jsonPrompt
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-valuenowalike; 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"(orrole="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.
Answer Sources
The numbered favicon cards behind one AI answer — a scrolling strip or an expandable list, the retrieved excerpt one hover or one Tab away, dead sources that refuse to link, and four data states.
Moderation Notice
Content-policy interruption states for chat — blocked input, withheld response and softened answer, with category chips that cross-highlight the flagged passage, a one-shot appeal action, and role=alert only when something was actually stopped.