{"slug":"confidence-indicator","category":"indicators","job":"trust","added":"2026-09-12T20:49Z","title":"Confidence Indicator","description":"A badge or inline dot signaling how sure an AI answer or extraction is.","url":"https://ai-patterns.app/patterns/indicators/confidence-indicator","markdown":"https://ai-patterns.app/patterns/indicators/confidence-indicator.md","json":"https://ai-patterns.app/api/patterns/indicators/confidence-indicator","seenIn":[{"team":"box","source":"https://blog.box.com/confidence-scores-box-ai-extract-know-when-rely-your-extractions"}],"spec":"# Confidence Indicator\n\n## Summary\nA 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.\n\n## When to use\n- Next to an answer, classification, or extraction whose correctness genuinely varies (a document field extraction, an OCR read, a retrieval match, a generated label).\n- Anywhere a low-confidence result would otherwise look identical to a high-confidence one and the user could act on it without checking.\n- 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).\n\n## When not to use\n- 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.\n- 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.\n- With a precise percentage the underlying model doesn't actually produce or that isn't calibrated (see Content guidelines).\n\n## Anatomy\n- 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.\n- Label: the actual meaning in words — \"High confidence\", \"Medium confidence\", \"Low confidence\", or a domain-specific override (\"Likely correct\", \"Needs review\").\n- 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.\n- 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).\n\n## Behavior\n- Static by default — it reflects a value computed once, it doesn't animate or update on its own.\n- 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.\n- Clicking the dot variant (when it carries a reason) opens the same kind of disclosure, anchored near the dot.\n- Never interactive in a way that changes the underlying confidence — this component only displays a value it's given.\n\n## Content guidelines\n- 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.\n- 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.\n- 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\").\n- Low confidence should read as informative, not alarming — this is a \"double-check this\" signal, not an error.\n\n## Accessibility\n- 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.\n- The signal-bars glyph is `aria-hidden` — it's decorative reinforcement of the label, not an independent source of information.\n- The reason disclosure toggle needs `aria-expanded` and, ideally, is a real `<button>` so it's reachable and operable by keyboard.\n\n## Related patterns\n- **Inline Citation** — Use it instead when the fix for low confidence is checking the source, since a citation gives people somewhere to look.\n- **Tool Approval** — Use it instead when uncertainty is about an action and should gate it, not just be displayed.\n- **AI Disclosure Badge** — Use it as well when people also need to know the value was AI-made.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ConfidenceLevel = \"high\" | \"medium\" | \"low\";\n\nconst LEVEL_CONFIG: Record<\n  ConfidenceLevel,\n  { label: string; bars: number; barColor: string; dotColor: string }\n> = {\n  high: {\n    label: \"High confidence\",\n    bars: 3,\n    barColor: \"text-emerald-600 dark:text-emerald-400\",\n    dotColor: \"bg-emerald-500\",\n  },\n  medium: {\n    label: \"Medium confidence\",\n    bars: 2,\n    barColor: \"text-amber-600 dark:text-amber-400\",\n    dotColor: \"bg-amber-500\",\n  },\n  low: {\n    label: \"Low confidence\",\n    bars: 1,\n    barColor: \"text-red-600 dark:text-red-400\",\n    dotColor: \"bg-red-500\",\n  },\n};\n\nexport interface ConfidenceIndicatorProps {\n  level: ConfidenceLevel;\n  /** Overrides the default \"High/Medium/Low confidence\" label. */\n  label?: string;\n  /** One short, specific sentence explaining the level. Shown in an expandable disclosure (badge variant) or as the tooltip/accessible name (dot variant). */\n  reason?: string;\n  /** A calibrated 0-100 score to display alongside the band. Only pass this if the number is real — see pattern content guidelines. */\n  percentage?: number;\n  /** \"badge\" (default) for a standalone claim/response; \"dot\" for inline placement next to a word or value. */\n  variant?: \"badge\" | \"dot\";\n  className?: string;\n}\n\nexport function ConfidenceIndicator({\n  level,\n  label,\n  reason,\n  percentage,\n  variant = \"badge\",\n  className,\n}: ConfidenceIndicatorProps) {\n  const config = LEVEL_CONFIG[level];\n  const text = label ?? config.label;\n\n  if (variant === \"dot\") {\n    return <ConfidenceDot level={level} text={text} reason={reason} className={className} />;\n  }\n\n  return (\n    <ConfidenceBadge\n      level={level}\n      text={text}\n      reason={reason}\n      percentage={percentage}\n      className={className}\n    />\n  );\n}\n\nfunction ConfidenceBadge({\n  level,\n  text,\n  reason,\n  percentage,\n  className,\n}: {\n  level: ConfidenceLevel;\n  text: string;\n  reason?: string;\n  percentage?: number;\n  className?: string;\n}) {\n  const [expanded, setExpanded] = React.useState(false);\n  const config = LEVEL_CONFIG[level];\n  const canExpand = Boolean(reason);\n\n  return (\n    <div className={cn(\"inline-flex max-w-full flex-col items-start\", className)}>\n      <button\n        type=\"button\"\n        onClick={() => canExpand && setExpanded((v) => !v)}\n        aria-expanded={canExpand ? expanded : undefined}\n        className={cn(\n          \"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\",\n          canExpand ? \"cursor-pointer hover:bg-accent\" : \"cursor-default\"\n        )}\n      >\n        <SignalBars level={level} className={config.barColor} />\n        <span>{text}</span>\n        {typeof percentage === \"number\" && (\n          <span className=\"text-muted-foreground\">· {Math.round(percentage)}%</span>\n        )}\n        {canExpand && (\n          <ChevronDown\n            className={cn(\"size-3 text-muted-foreground transition-transform\", expanded && \"rotate-180\")}\n            aria-hidden\n          />\n        )}\n      </button>\n\n      <AnimatePresence initial={false}>\n        {canExpand && expanded && (\n          <motion.p\n            initial={{ opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={{ opacity: 0, height: 0 }}\n            transition={{ duration: 0.15, ease: \"easeOut\" }}\n            className=\"overflow-hidden pl-1 pt-1.5 text-xs text-muted-foreground\"\n          >\n            {reason}\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction ConfidenceDot({\n  level,\n  text,\n  reason,\n  className,\n}: {\n  level: ConfidenceLevel;\n  text: string;\n  reason?: string;\n  className?: string;\n}) {\n  const config = LEVEL_CONFIG[level];\n  const accessibleName = reason ? `${text}: ${reason}` : text;\n\n  return (\n    <span\n      role=\"img\"\n      aria-label={accessibleName}\n      title={accessibleName}\n      className={cn(\"inline-flex size-2 shrink-0 translate-y-[-1px] rounded-full\", config.dotColor, className)}\n    />\n  );\n}\n\nfunction SignalBars({ level, className }: { level: ConfidenceLevel; className?: string }) {\n  const filled = LEVEL_CONFIG[level].bars;\n  const heights = [5, 8, 11];\n\n  return (\n    <span className=\"flex items-end gap-0.5\" aria-hidden>\n      {heights.map((height, i) => (\n        <span\n          key={height}\n          style={{ height }}\n          className={cn(\"w-1 rounded-sm\", i < filled ? cn(\"bg-current\", className) : \"bg-muted-foreground/25\")}\n        />\n      ))}\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}