indicators
Confidence Indicator
A badge or inline dot signaling how sure an AI answer or extraction is.
Badge, with calibrated score
Inline dot, per-claim
The invoice total is $4,210.00, due on the last business day of the month.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export type ConfidenceLevel = "high" | "medium" | "low";
const LEVEL_CONFIG: Record<
ConfidenceLevel,
{ label: string; bars: number; barColor: string; dotColor: string }
> = {
high: {
label: "High confidence",
bars: 3,
barColor: "text-emerald-600 dark:text-emerald-400",
dotColor: "bg-emerald-500",
},
medium: {
label: "Medium confidence",
bars: 2,
barColor: "text-amber-600 dark:text-amber-400",
dotColor: "bg-amber-500",
},
low: {
label: "Low confidence",
bars: 1,
barColor: "text-red-600 dark:text-red-400",
dotColor: "bg-red-500",
},
};
export interface ConfidenceIndicatorProps {
level: ConfidenceLevel;
/** Overrides the default "High/Medium/Low confidence" label. */
label?: string;
/** One short, specific sentence explaining the level. Shown in an expandable disclosure (badge variant) or as the tooltip/accessible name (dot variant). */
reason?: string;
/** A calibrated 0-100 score to display alongside the band. Only pass this if the number is real — see pattern content guidelines. */
percentage?: number;
/** "badge" (default) for a standalone claim/response; "dot" for inline placement next to a word or value. */
variant?: "badge" | "dot";
className?: string;
}
export function ConfidenceIndicator({
level,
label,
reason,
percentage,
variant = "badge",
className,
}: ConfidenceIndicatorProps) {
const config = LEVEL_CONFIG[level];
const text = label ?? config.label;
if (variant === "dot") {
return <ConfidenceDot level={level} text={text} reason={reason} className={className} />;
}
return (
<ConfidenceBadge
level={level}
text={text}
reason={reason}
percentage={percentage}
className={className}
/>
);
}
function ConfidenceBadge({
level,
text,
reason,
percentage,
className,
}: {
level: ConfidenceLevel;
text: string;
reason?: string;
percentage?: number;
className?: string;
}) {
const [expanded, setExpanded] = React.useState(false);
const config = LEVEL_CONFIG[level];
const canExpand = Boolean(reason);
return (
<div className={cn("inline-flex max-w-full flex-col items-start", className)}>
<button
type="button"
onClick={() => canExpand && setExpanded((v) => !v)}
aria-expanded={canExpand ? expanded : undefined}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border bg-card px-2.5 py-1 text-xs font-medium text-foreground transition-colors",
canExpand ? "cursor-pointer hover:bg-accent" : "cursor-default"
)}
>
<SignalBars level={level} className={config.barColor} />
<span>{text}</span>
{typeof percentage === "number" && (
<span className="text-muted-foreground">· {Math.round(percentage)}%</span>
)}
{canExpand && (
<ChevronDown
className={cn("size-3 text-muted-foreground transition-transform", expanded && "rotate-180")}
aria-hidden
/>
)}
</button>
<AnimatePresence initial={false}>
{canExpand && expanded && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="overflow-hidden pl-1 pt-1.5 text-xs text-muted-foreground"
>
{reason}
</motion.p>
)}
</AnimatePresence>
</div>
);
}
function ConfidenceDot({
level,
text,
reason,
className,
}: {
level: ConfidenceLevel;
text: string;
reason?: string;
className?: string;
}) {
const config = LEVEL_CONFIG[level];
const accessibleName = reason ? `${text}: ${reason}` : text;
return (
<span
role="img"
aria-label={accessibleName}
title={accessibleName}
className={cn("inline-flex size-2 shrink-0 translate-y-[-1px] rounded-full", config.dotColor, className)}
/>
);
}
function SignalBars({ level, className }: { level: ConfidenceLevel; className?: string }) {
const filled = LEVEL_CONFIG[level].bars;
const heights = [5, 8, 11];
return (
<span className="flex items-end gap-0.5" aria-hidden>
{heights.map((height, i) => (
<span
key={height}
style={{ height }}
className={cn("w-1 rounded-sm", i < filled ? cn("bg-current", className) : "bg-muted-foreground/25")}
/>
))}
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Confidence Indicator
## Summary
A small visual signal attached to an AI-generated claim, answer, or extraction that communicates how sure the system is — as a qualitative band (High / Medium / Low), not a fabricated precise number. Comes in two shapes: a labeled badge for a whole response or block, and a compact dot for an inline, per-claim signal inside running text.
## When to use
- Next to an answer, classification, or extraction whose correctness genuinely varies (a document field extraction, an OCR read, a retrieval match, a generated label).
- Anywhere a low-confidence result would otherwise look identical to a high-confidence one and the user could act on it without checking.
- Per-claim, inline, when a single response mixes well-supported and shaky statements (use the dot variant next to the specific sentence or value, not the whole message).
## When not to use
- On every single response by default — if confidence is uniformly high or the system has no real signal for it, showing the indicator anyway trains users to ignore it.
- As a substitute for actually improving the answer. A low-confidence badge is not a fix for a bad retrieval or a weak extraction; use it as a signal, and pair it with a way to verify or correct (a source link, an edit affordance), not just a color.
- With a precise percentage the underlying model doesn't actually produce or that isn't calibrated (see Content guidelines).
## Anatomy
- Signal bars: three small bars of increasing height; the number filled (1/2/3) encodes the level. Decorative only — never the sole carrier of meaning.
- Label: the actual meaning in words — "High confidence", "Medium confidence", "Low confidence", or a domain-specific override ("Likely correct", "Needs review").
- Optional reason disclosure: a short, specific explanation ("Only one source mentions this date"), revealed on click/tap for the badge variant. Omit if there's nothing specific to say — don't invent a generic filler reason.
- Dot variant: the signal bars collapse to a single small colored dot for inline placement next to a word or value, with the level/reason available via title text or an adjacent tooltip rather than a visible label (there's no room for one inline).
## Behavior
- Static by default — it reflects a value computed once, it doesn't animate or update on its own.
- The badge variant's reason (when provided) is collapsed by default and expands in place on click; it never opens as a popover that covers surrounding content.
- Clicking the dot variant (when it carries a reason) opens the same kind of disclosure, anchored near the dot.
- Never interactive in a way that changes the underlying confidence — this component only displays a value it's given.
## Content guidelines
- Prefer three bands (High/Medium/Low) over a numeric percentage. Most systems don't have a genuinely calibrated confidence score, and a number like "87%" implies precision that misleads more than a band does.
- If you do show a number, it must come from an actually calibrated source (e.g., a model's real logprob-derived score, a retrieval similarity above a validated threshold) — never a number invented to look precise.
- Reason text is one short, concrete sentence about *why* the confidence is what it is, not a restatement of the label ("Low confidence" → reason should not be "This has low confidence").
- Low confidence should read as informative, not alarming — this is a "double-check this" signal, not an error.
## Accessibility
- Never convey the level by color alone — the text label (badge variant) or accessible name (dot variant, via `aria-label`/`title`) must state it in words for colorblind users and screen readers.
- The signal-bars glyph is `aria-hidden` — it's decorative reinforcement of the label, not an independent source of information.
- The reason disclosure toggle needs `aria-expanded` and, ideally, is a real `<button>` so it's reachable and operable by keyboard.
## Related patterns
- Inline Citation — when the reason for low confidence is "check the source," a citation marker is often a better fix than a bare confidence dot.
- Tool Approval — for actions (not claims) where uncertainty should gate execution rather than just being displayed.