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** — Use it instead when the fix for low confidence is checking the source, since a citation gives people somewhere to look.
- **Tool Approval** — Use it instead when uncertainty is about an action and should gate it, not just be displayed.
- **AI Disclosure Badge** — Use it as well when people also need to know the value was AI-made.
Do’s and don’ts
Do use it for
- 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).
Don’t use it for
- 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).
Related patterns
Inline Citation
Use it instead when the fix for low confidence is checking the source, since a citation gives people somewhere to look.
Tool Approval
Use it instead when uncertainty is about an action and should gate it, not just be displayed.
AI Disclosure Badge
Use it as well when people also need to know the value was AI-made.