{"slug":"ai-disclosure-badge","category":"indicators","job":"trust","added":"2026-11-07T22:47Z","title":"AI Disclosure Badge","description":"An AI-generated label in chip, image-overlay and inline forms that opens a provenance card: model, time, and whether a person edited it.","url":"https://ai-patterns.app/patterns/indicators/ai-disclosure-badge","markdown":"https://ai-patterns.app/patterns/indicators/ai-disclosure-badge.md","json":"https://ai-patterns.app/api/patterns/indicators/ai-disclosure-badge","seenIn":[{"team":"Intercom","source":"https://www.intercom.com/help/en/articles/11712008-ai-agent-disclosure"},{"team":"zapier","source":"https://help.zapier.com/hc/en-us/articles/27915202201357-Add-a-disclosure-message-to-your-chatbot"}],"spec":"# AI Disclosure Badge\n\n## Summary\nA small \"AI-generated\" label attached to content an AI produced, which opens a card with its provenance: which model, when, and whether a person reviewed or edited it. It makes the origin of content clear without getting in the way of reading it.\n\n## When to use\n- Content an AI wrote, drew or summarised that sits next to human-made content — docs, feeds, product listings, support replies.\n- Generated media that may be shared outside the product (images, video, audio), where the label should travel with it.\n- Anywhere regulation or policy requires AI content to be labelled.\n\n## When not to use\n- Inside a chat with an assistant, where every reply is obviously AI. Labelling each message is noise.\n- As the only safeguard for generated media that leaves the product — pair it with a machine-readable watermark or content credentials.\n- As a quality or confidence signal. Use Confidence Indicator for \"how sure\", this pattern only says \"who made it\".\n\n## Anatomy\n- Badge: a sparkle mark and a short label, in one of three variants — chip (next to a title), overlay (corner of an image), inline (byline under text).\n- Provenance card: a one-line headline (\"Created with AI\" / \"Created with AI, reviewed by a person\"), then model, generated time and editor rows.\n- Optional caveat line at the bottom of the card.\n\n## Behavior\n- Hover or focus previews the card; a click pins it open until the next click, a click outside or Escape.\n- When a person edits the content, the label changes from \"AI-generated\" to \"AI-assisted\" and the card names the editor. Don't drop the badge after an edit.\n- With no details, the badge renders as plain text and is not interactive.\n- The badge stays visible when content is copied into another surface in the product (quotes, embeds, exports).\n\n## Content guidelines\n- Use plain words: \"AI-generated\", \"AI-assisted\". Avoid \"Magic\" or brand-only names that don't say AI.\n- Keep the caveat factual and short: \"May contain mistakes. Check important facts.\"\n- Show times in the user's locale and relative where it helps (\"Today, 14:02\").\n\n## Accessibility\n- The label is real text, not only an icon, so it is read by screen readers and survives high-contrast modes.\n- With details, the badge is a button with `aria-expanded` and `aria-controls` pointing at the card; the card is reachable on keyboard focus.\n- The overlay variant keeps WCAG AA contrast on any image by sitting on a dark translucent backing.\n\n## Related patterns\n- **Confidence Indicator** — Use it as well when AI-made content also needs a signal of how reliable it is.\n- **Inline Citation** — Use it as well when people need to see what a claim is based on, not just who made it.\n- **Sources Stack** — Use it as well to show every source an AI answer drew on.\n- **Source Trust Card** — Use it instead when the question is how trustworthy a source is rather than whether the content is AI-made.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Sparkles } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface AiDisclosureDetails {\n  /** The model or feature that produced the content. */\n  model?: string;\n  /** When it was generated, already formatted (\"Today, 14:02\"). */\n  generatedAt?: string;\n  /** Who reviewed or edited it after generation. Changes the label to \"AI-assisted\". */\n  editedBy?: string;\n  /** A short caveat, such as \"May contain mistakes. Check important facts.\" */\n  note?: string;\n}\n\nexport interface AiDisclosureBadgeProps {\n  /** Defaults to \"AI-generated\", or \"AI-assisted\" once a person has edited it. */\n  label?: string;\n  details?: AiDisclosureDetails;\n  /**\n   * \"chip\" — a bordered pill next to a title or in a card header.\n   * \"overlay\" — a translucent pill for the corner of an image or video.\n   * \"inline\" — icon and label only, for a byline or under a paragraph.\n   */\n  variant?: \"chip\" | \"overlay\" | \"inline\";\n  className?: string;\n}\n\nexport function AiDisclosureBadge({ label, details, variant = \"chip\", className }: AiDisclosureBadgeProps) {\n  const [open, setOpen] = React.useState(false);\n  const [pinned, setPinned] = React.useState(false);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const closeTimer = React.useRef<number | undefined>(undefined);\n  const id = React.useId();\n\n  const text = label ?? (details?.editedBy ? \"AI-assisted\" : \"AI-generated\");\n  const hasDetails = Boolean(details && (details.model || details.generatedAt || details.editedBy || details.note));\n\n  React.useEffect(() => {\n    if (!pinned) return;\n    function handle(e: MouseEvent) {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {\n        setPinned(false);\n        setOpen(false);\n      }\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [pinned]);\n\n  React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);\n\n  function show() {\n    window.clearTimeout(closeTimer.current);\n    setOpen(true);\n  }\n\n  function hide() {\n    if (pinned) return;\n    // A short delay lets the pointer travel from the badge into the card.\n    closeTimer.current = window.setTimeout(() => setOpen(false), 120);\n  }\n\n  const badgeClass = cn(\n    \"inline-flex items-center gap-1 text-xs font-medium whitespace-nowrap\",\n    variant === \"chip\" && \"rounded-full border bg-background px-2 py-0.5 text-muted-foreground\",\n    variant === \"overlay\" && \"rounded-full bg-black/55 px-2 py-0.5 text-white backdrop-blur-sm\",\n    variant === \"inline\" && \"text-muted-foreground\",\n    hasDetails && \"cursor-pointer transition-colors\",\n    hasDetails && variant === \"chip\" && \"hover:text-foreground\",\n    hasDetails && variant === \"inline\" && \"underline decoration-dotted underline-offset-2 hover:text-foreground\"\n  );\n\n  const content = (\n    <>\n      <Sparkles className=\"size-3\" aria-hidden />\n      {text}\n    </>\n  );\n\n  if (!hasDetails) {\n    return <span className={cn(badgeClass, className)}>{content}</span>;\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={cn(\"relative inline-flex\", className)}\n      onMouseEnter={show}\n      onMouseLeave={hide}\n    >\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        aria-controls={`${id}-details`}\n        onClick={() => {\n          // Hover previews the card; a click pins it open until the next click.\n          setPinned(!pinned);\n          setOpen(!pinned);\n        }}\n        onFocus={show}\n        onBlur={(e) => {\n          if (!rootRef.current?.contains(e.relatedTarget as Node)) {\n            setPinned(false);\n            setOpen(false);\n          }\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            setPinned(false);\n            setOpen(false);\n          }\n        }}\n        className={cn(badgeClass, \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\")}\n      >\n        {content}\n      </button>\n\n      <AnimatePresence>\n        {open && (\n          <motion.span\n            id={`${id}-details`}\n            role=\"note\"\n            initial={{ opacity: 0, y: -3 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -3 }}\n            transition={{ duration: 0.12, ease: \"easeOut\" }}\n            className=\"absolute top-full left-0 z-20 mt-1.5 block w-60 rounded-xl border bg-popover p-3 text-left text-xs font-normal whitespace-normal text-popover-foreground shadow-lg\"\n          >\n            <span className=\"block font-medium text-foreground\">\n              {details?.editedBy ? \"Created with AI, reviewed by a person\" : \"Created with AI\"}\n            </span>\n            <dl className=\"mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1\">\n              {details?.model && <Row term=\"Model\" value={details.model} />}\n              {details?.generatedAt && <Row term=\"Generated\" value={details.generatedAt} />}\n              {details?.editedBy && <Row term=\"Edited by\" value={details.editedBy} />}\n            </dl>\n            {details?.note && <span className=\"mt-2 block border-t pt-2 text-pretty text-muted-foreground\">{details.note}</span>}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Row({ term, value }: { term: string; value: string }) {\n  return (\n    <>\n      <dt className=\"text-muted-foreground\">{term}</dt>\n      <dd className=\"text-foreground\">{value}</dd>\n    </>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}