AI Patterns

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.