AI Disclosure

Tells a reader they are looking at AI, and what is actually known about it.

Generated by AI

Installation

Terminal
pnpm dlx @dowel-ui/cli add ai-disclosure

Installs popover as well, because this component imports it.

npm packages installed: class-variance-authority.

Accessibility

The text is the disclosure and the icon is decoration, marked aria-hidden — a sparkle alone is a mark a sighted reader may not decode and a screen reader passes over in silence, which is the exact failure this component exists to prevent. Each of the four kinds carries wording of its own, so the distinction between generated, manipulated and human-reviewed survives for anyone who cannot see the styling. The provenance panel states in words whether anyone checked the claims and who, rather than implying it with a tick, and its three states are told apart by their sentences rather than by colour. Scope: this renders the human-visible disclosures of EU AI Act Article 50(1) and 50(4). It cannot satisfy 50(2), which requires machine-readable marking inside the artifact by whoever generated it, and no React component can. Not legal advice.

Props

AIDisclosure

PropTypeDefault
kind (required)DisclosureKind
icon

Replaces the mark. The Commission publishes three icons for this and lets anyone use them without attribution — they are deliberately not bundled here, because an official mark shipped inside a component library ends up on content nobody checked. Bring your own, and the Commission's own words apply: using them "does not establish legal compliance by itself".

ReactNode
label

Replaces the default wording. The default is a starting point, not a rule.

ReactNode
variant"inline" | "banner" | "overlay""inline"

Plus every attribute of <div> except children.

AIDisclosureProvenance

PropTypeDefault
claims (required)

What is being claimed about this content, and by whom. Claims, not findings. They arrive as data — from a C2PA manifest your server read, from your own pipeline, from a vendor — and this renders them.

ProvenanceClaim[]
source

Who asserts the claims. Named, because an unattributed claim is a rumour.

string
triggerLabelstring"Content details"
verified

Whether something you trust checked the claims, and said so. Supplied, never computed. Verifying a C2PA manifest means parsing signed COSE and walking a certificate chain; doing that in the browser would mean a wasm blob you cannot read, and "you own the source" would stop being true. It also cannot be trusted client-side even when it succeeds, because the page doing the checking is the page making the claim. Verify on a server and pass the answer down. Left undefined, this says so.

boolean

Plus every attribute of <button> except children.

Quality

8/9 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedfails
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add ai-disclosure writes into your project, with imports rewritten to your own path alias.

ui/ai-disclosure.tsx
"use client";

import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentPropsWithRef, ReactNode } from "react";

import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import { focusRing, iconSlot } from "@/lib/styles";
import { cn } from "@/lib/utils";

/**
 * Telling someone they are looking at AI.
 *
 * Not one registry ships this — not AI Elements, assistant-ui, prompt-kit,
 * CopilotKit or shadcn — while every one of them ships the chat surface that
 * needs it. The gap is the reason this exists.
 *
 * The four `kind`s are not invented. They are the four human-visible situations
 * the EU AI Act's Article 50 actually creates, which has applied since 2 August
 * 2026: you are talking to a machine (50(1)); this media was generated or
 * manipulated and resembles something real (50(4), "deep fakes"); this text was
 * published to inform the public (50(4)); and the assisted case, which the same
 * paragraph exempts where there is human review or editorial control — worth
 * saying out loud rather than leaving to inference.
 *
 * What this cannot do, and what no React component can: Article 50(2) requires
 * synthetic output to be marked *in a machine-readable format*, in the artifact
 * itself, by whoever generated it. Rendering a badge does not do that, and a
 * component that let you believe otherwise would be worse than none. Read the
 * accessibility note and the docs page: this is a disclosure control, not a
 * compliance product, and nothing here is legal advice.
 */

export type DisclosureKind = "interaction" | "generated" | "manipulated" | "assisted";

/**
 * What each kind says by default.
 *
 * Phrased for a reader rather than for a lawyer, and every one of them is
 * overridable — the Act says the disclosure must be clear, not that it must use
 * these words, and the right words depend on the product.
 */
const DEFAULT_LABEL: Record<DisclosureKind, string> = {
  interaction: "You are chatting with an AI assistant",
  generated: "Generated by AI",
  manipulated: "This media was altered by AI",
  assisted: "AI-assisted, reviewed by a person",
};

const disclosureVariants = cva(
  cn(
    "inline-flex w-fit items-center gap-1.5 font-medium",
    "[&_svg:not([class*='size-'])]:size-3.5",
    iconSlot,
  ),
  {
    variants: {
      variant: {
        /** Sits beside the thing it describes. */
        inline: "rounded-full border border-border-strong px-2 py-0.5 text-xs",
        /** Above a conversation, where Article 50(1) usually belongs. */
        banner:
          "w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground",
        /**
         * Over the corner of an image or video. `--overlay` is a dark scrim in
         * every shipped theme, so the text is white on purpose rather than
         * following the theme — a label that inverts over a photograph is
         * unreadable on half of them.
         */
        overlay: "rounded-md bg-overlay px-2 py-1 text-2xs text-white backdrop-blur-sm",
      },
    },
    defaultVariants: { variant: "inline" },
  },
);

function SparkIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path
        d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export interface AIDisclosureProps
  extends
    Omit<ComponentPropsWithRef<"div">, "children">,
    VariantProps<typeof disclosureVariants> {
  kind: DisclosureKind;
  /** Replaces the default wording. The default is a starting point, not a rule. */
  label?: ReactNode;
  /**
   * Replaces the mark. The Commission publishes three icons for this and lets
   * anyone use them without attribution — they are deliberately not bundled
   * here, because an official mark shipped inside a component library ends up
   * on content nobody checked. Bring your own, and the Commission's own words
   * apply: using them "does not establish legal compliance by itself".
   */
  icon?: ReactNode;
  /** A provenance panel, or anything else that belongs after the label. */
  children?: ReactNode;
}

/**
 * A disclosure, in words.
 *
 * The icon is decorative and the text is the disclosure. A sparkle alone is not
 * a notice — it is a decoration that a sighted reader may or may not decode and
 * that a screen reader passes over in silence, which is the failure mode the
 * whole component exists to avoid.
 */
export function AIDisclosure({
  className,
  kind,
  variant,
  label,
  icon,
  children,
  ...props
}: AIDisclosureProps) {
  return (
    <div
      data-slot="ai-disclosure"
      data-kind={kind}
      className={cn(disclosureVariants({ variant }), className)}
      {...props}
    >
      {icon ?? <SparkIcon />}
      <span>{label ?? DEFAULT_LABEL[kind]}</span>
      {children}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  Provenance                                                         */
/* ------------------------------------------------------------------ */

export interface ProvenanceClaim {
  label: string;
  value: ReactNode;
}

export interface AIDisclosureProvenanceProps extends Omit<
  ComponentPropsWithRef<"button">,
  "children"
> {
  /**
   * What is being claimed about this content, and by whom.
   *
   * Claims, not findings. They arrive as data — from a C2PA manifest your
   * server read, from your own pipeline, from a vendor — and this renders them.
   */
  claims: ProvenanceClaim[];
  /** Who asserts the claims. Named, because an unattributed claim is a rumour. */
  source?: string;
  /**
   * Whether something you trust checked the claims, and said so.
   *
   * Supplied, never computed. Verifying a C2PA manifest means parsing signed
   * COSE and walking a certificate chain; doing that in the browser would mean
   * a wasm blob you cannot read, and "you own the source" would stop being
   * true. It also cannot be trusted client-side even when it succeeds, because
   * the page doing the checking is the page making the claim. Verify on a
   * server and pass the answer down. Left undefined, this says so.
   */
  verified?: boolean;
  triggerLabel?: string;
}

type CheckStatus = "verified" | "failed" | "unchecked";

function statusText(status: CheckStatus, source?: string): string {
  const by = source ? ` by ${source}` : "";
  if (status === "verified") return `Checked${by} and the claims matched.`;
  if (status === "failed") return `Checked${by} and the claims did not match.`;
  return "Not checked. These are claims made about the content, not findings.";
}

/**
 * The metadata behind a disclosure, presented as claims rather than proof.
 *
 * The distinction is the entire point. "Made with Model X on 3 September" looks
 * like a fact and is a string somebody put in a file. Rendering it beside a
 * checkmark would launder an assertion into evidence, so the panel names who is
 * asserting and states plainly whether anyone checked.
 */
export function AIDisclosureProvenance({
  className,
  claims,
  source,
  verified,
  triggerLabel = "Content details",
  ...props
}: AIDisclosureProvenanceProps) {
  const status = verified === undefined ? "unchecked" : verified ? "verified" : "failed";

  return (
    <Popover>
      <PopoverTrigger asChild>
        <button
          type="button"
          data-slot="ai-disclosure-provenance-trigger"
          className={cn(
            "rounded-sm underline decoration-dotted underline-offset-2 hover:decoration-solid",
            focusRing,
            className,
          )}
          {...props}
        >
          {triggerLabel}
        </button>
      </PopoverTrigger>
      <PopoverContent aria-label={triggerLabel} className="w-80 text-xs">
        <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
          {claims.map((claim) => (
            <div key={claim.label} className="contents">
              <dt className="text-muted-foreground">{claim.label}</dt>
              <dd className="break-words">{claim.value}</dd>
            </div>
          ))}
        </dl>
        {/* Said in text, and last, so it qualifies everything above it rather
            than being a tick a reader has to interpret. */}
        <p
          data-slot="ai-disclosure-provenance-status"
          data-status={status}
          className={cn(
            "mt-3 border-t border-border pt-2",
            status === "failed" ? "text-destructive" : "text-muted-foreground",
          )}
        >
          {statusText(status, source)}
        </p>
      </PopoverContent>
    </Popover>
  );
}

export { disclosureVariants };