AI Patterns

AI Disclosure BadgeNew

An AI-generated label in chip, image-overlay and inline forms that opens a provenance card: model, time, and whether a person edited it.

JobTrust
Seen in

Q3 product summary

Revenue grew 18% quarter over quarter, led by the new team plan. Churn held steady at 2.1%.

"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Sparkles } from "lucide-react";

import { cn } from "@/lib/utils";

export interface AiDisclosureDetails {
  /** The model or feature that produced the content. */
  model?: string;
  /** When it was generated, already formatted ("Today, 14:02"). */
  generatedAt?: string;
  /** Who reviewed or edited it after generation. Changes the label to "AI-assisted". */
  editedBy?: string;
  /** A short caveat, such as "May contain mistakes. Check important facts." */
  note?: string;
}

export interface AiDisclosureBadgeProps {
  /** Defaults to "AI-generated", or "AI-assisted" once a person has edited it. */
  label?: string;
  details?: AiDisclosureDetails;
  /**
   * "chip" — a bordered pill next to a title or in a card header.
   * "overlay" — a translucent pill for the corner of an image or video.
   * "inline" — icon and label only, for a byline or under a paragraph.
   */
  variant?: "chip" | "overlay" | "inline";
  className?: string;
}

export function AiDisclosureBadge({ label, details, variant = "chip", className }: AiDisclosureBadgeProps) {
  const [open, setOpen] = React.useState(false);
  const [pinned, setPinned] = React.useState(false);
  const rootRef = React.useRef<HTMLSpanElement>(null);
  const closeTimer = React.useRef<number | undefined>(undefined);
  const id = React.useId();

  const text = label ?? (details?.editedBy ? "AI-assisted" : "AI-generated");
  const hasDetails = Boolean(details && (details.model || details.generatedAt || details.editedBy || details.note));

  React.useEffect(() => {
    if (!pinned) return;
    function handle(e: MouseEvent) {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
        setPinned(false);
        setOpen(false);
      }
    }
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, [pinned]);

  React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);

  function show() {
    window.clearTimeout(closeTimer.current);
    setOpen(true);
  }

  function hide() {
    if (pinned) return;
    // A short delay lets the pointer travel from the badge into the card.
    closeTimer.current = window.setTimeout(() => setOpen(false), 120);
  }

  const badgeClass = cn(
    "inline-flex items-center gap-1 text-xs font-medium whitespace-nowrap",
    variant === "chip" && "rounded-full border bg-background px-2 py-0.5 text-muted-foreground",
    variant === "overlay" && "rounded-full bg-black/55 px-2 py-0.5 text-white backdrop-blur-sm",
    variant === "inline" && "text-muted-foreground",
    hasDetails && "cursor-pointer transition-colors",
    hasDetails && variant === "chip" && "hover:text-foreground",
    hasDetails && variant === "inline" && "underline decoration-dotted underline-offset-2 hover:text-foreground"
  );

  const content = (
    <>
      <Sparkles className="size-3" aria-hidden />
      {text}
    </>
  );

  if (!hasDetails) {
    return <span className={cn(badgeClass, className)}>{content}</span>;
  }

  return (
    <span
      ref={rootRef}
      className={cn("relative inline-flex", className)}
      onMouseEnter={show}
      onMouseLeave={hide}
    >
      <button
        type="button"
        aria-expanded={open}
        aria-controls={`${id}-details`}
        onClick={() => {
          // Hover previews the card; a click pins it open until the next click.
          setPinned(!pinned);
          setOpen(!pinned);
        }}
        onFocus={show}
        onBlur={(e) => {
          if (!rootRef.current?.contains(e.relatedTarget as Node)) {
            setPinned(false);
            setOpen(false);
          }
        }}
        onKeyDown={(e) => {
          if (e.key === "Escape") {
            setPinned(false);
            setOpen(false);
          }
        }}
        className={cn(badgeClass, "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring")}
      >
        {content}
      </button>

      <AnimatePresence>
        {open && (
          <motion.span
            id={`${id}-details`}
            role="note"
            initial={{ opacity: 0, y: -3 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -3 }}
            transition={{ duration: 0.12, ease: "easeOut" }}
            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"
          >
            <span className="block font-medium text-foreground">
              {details?.editedBy ? "Created with AI, reviewed by a person" : "Created with AI"}
            </span>
            <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
              {details?.model && <Row term="Model" value={details.model} />}
              {details?.generatedAt && <Row term="Generated" value={details.generatedAt} />}
              {details?.editedBy && <Row term="Edited by" value={details.editedBy} />}
            </dl>
            {details?.note && <span className="mt-2 block border-t pt-2 text-pretty text-muted-foreground">{details.note}</span>}
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

function Row({ term, value }: { term: string; value: string }) {
  return (
    <>
      <dt className="text-muted-foreground">{term}</dt>
      <dd className="text-foreground">{value}</dd>
    </>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# AI Disclosure Badge

## Summary
A 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.

## When to use
- Content an AI wrote, drew or summarised that sits next to human-made content — docs, feeds, product listings, support replies.
- Generated media that may be shared outside the product (images, video, audio), where the label should travel with it.
- Anywhere regulation or policy requires AI content to be labelled.

## When not to use
- Inside a chat with an assistant, where every reply is obviously AI. Labelling each message is noise.
- As the only safeguard for generated media that leaves the product — pair it with a machine-readable watermark or content credentials.
- As a quality or confidence signal. Use Confidence Indicator for "how sure", this pattern only says "who made it".

## Anatomy
- 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).
- Provenance card: a one-line headline ("Created with AI" / "Created with AI, reviewed by a person"), then model, generated time and editor rows.
- Optional caveat line at the bottom of the card.

## Behavior
- Hover or focus previews the card; a click pins it open until the next click, a click outside or Escape.
- 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.
- With no details, the badge renders as plain text and is not interactive.
- The badge stays visible when content is copied into another surface in the product (quotes, embeds, exports).

## Content guidelines
- Use plain words: "AI-generated", "AI-assisted". Avoid "Magic" or brand-only names that don't say AI.
- Keep the caveat factual and short: "May contain mistakes. Check important facts."
- Show times in the user's locale and relative where it helps ("Today, 14:02").

## Accessibility
- The label is real text, not only an icon, so it is read by screen readers and survives high-contrast modes.
- With details, the badge is a button with `aria-expanded` and `aria-controls` pointing at the card; the card is reachable on keyboard focus.
- The overlay variant keeps WCAG AA contrast on any image by sitting on a dark translucent backing.

## Related patterns
- **Confidence Indicator** — Use it as well when AI-made content also needs a signal of how reliable it is.
- **Inline Citation** — Use it as well when people need to see what a claim is based on, not just who made it.
- **Sources Stack** — Use it as well to show every source an AI answer drew on.
- **Source Trust Card** — Use it instead when the question is how trustworthy a source is rather than whether the content is AI-made.

Do’s and don’ts

Do use it for

  • Content an AI wrote, drew or summarised that sits next to human-made content — docs, feeds, product listings, support replies.
  • Generated media that may be shared outside the product (images, video, audio), where the label should travel with it.
  • Anywhere regulation or policy requires AI content to be labelled.

Don’t use it for

  • Inside a chat with an assistant, where every reply is obviously AI. Labelling each message is noise.
  • As the only safeguard for generated media that leaves the product — pair it with a machine-readable watermark or content credentials.
  • As a quality or confidence signal. Use Confidence Indicator for "how sure", this pattern only says "who made it".