AI

AI Badge

A provenance chip for AI-touched content — generated / assisted / human-edited, with the model and timestamp behind a tooltip or popover and a contrast-safe overlay mode for images.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { PenLine, Sparkles, WandSparkles } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Provenance table
 *
 * One editable row per claim the badge is allowed to make. The scale is
 * deliberately short — "who did the work" is a three-step ladder (model only,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ai-badge.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AiBadge" component: a provenance chip that
labels content a model touched, optionally revealing the model and timestamp, and
able to sit as a corner overlay on an image. Dependencies: lucide-react for the
glyphs, the unified `radix-ui` package for the Popover, a shadcn-style Tooltip,
and a cn() class merger.

Contract
- forwardRef<HTMLElement>, extends React.HTMLAttributes<HTMLElement> minus
  children. The root is a <span> when the chip has nothing to reveal and a
  <button type="button"> when it does; cast the forwarded ref in each branch.
- provenance?: "generated" | "assisted" | "edited" (default "generated"). Keep it
  as one editable table, one row per claim:
    generated -> label "AI generated",  var(--chart-1), sparkles glyph
    assisted  -> label "AI assisted",   var(--chart-4), wand glyph
    edited    -> label "Human edited",  var(--chart-2), pen glyph
  Each row also carries a one-sentence description of what the claim means.
- label?: ReactNode overrides the chip text; description?: ReactNode overrides the
  sentence at the top of the panel (null drops it).
- size?: "sm" | "md" | "lg" (default "md"): heights 5/6/7, glyphs 3/3.5/4, text
  0.6875rem / xs / sm.
- tone?: "soft" | "outline" (default "soft"). color?: string overrides the accent
  and must be a token such as "var(--primary)", never a literal.
- icon?: ReactNode — undefined keeps the provenance glyph, null/false renders no
  glyph, any node replaces it.
- showLabel?: boolean (default true); false collapses the chip to a square glyph.
- details?: "none" | "tooltip" | "popover" — the default is DERIVED, see below.
- model?: string, date?: Date | string, fields?: readonly { label: string; value:
  ReactNode }[], footer?: ReactNode.
- overlay?: "top-left" | "top-right" | "bottom-left" | "bottom-right".
- side/align for panel placement, formatDate?: (d: Date) => string,
  delayDuration?: number (tooltip only, default 200), flash?: boolean.

Behavior — affordance follows content
- hasMeta = model || date !== undefined || fields?.length || footer.
- Resolve the surface as: details ?? (footer ? "popover" : hasMeta ? "tooltip" :
  "none"). A chip with nothing behind it renders as a plain span: no tab stop, no
  pointer cursor, no hover ring. A control that does nothing when clicked is the
  exact failure this rule exists to prevent.
- A footer is interactive content, and a tooltip is unreachable by pointer or
  touch, so a footer upgrades the default to a popover; in tooltip mode the footer
  is dropped rather than rendered somewhere no one can click.
- Tooltip mode serves pointer + keyboard only — say so in your docs: touch users
  need the popover. Wrap the tooltip in its own Provider so the component is
  self-contained; a nested provider only re-scopes the delay.
- Use the popover primitive's Root/Trigger/Portal/Content so outside-click, Esc,
  focus return, aria-expanded and aria-controls come for free. Never hand-roll it.

Behavior — the details panel
- Build the rows when the panel opens, in order: "Model", "Generated" (from date),
  then every entry of `fields`. If the list ends up empty, render no <dl> at all.
- A `date` string prints verbatim (already localized, or relative like "2 hours
  ago"). A Date goes through formatDate, default Intl.DateTimeFormat with
  dateStyle "medium" and timeStyle "short". A Date with NaN time is DROPPED — a
  provenance panel must never print "Invalid Date".
- Do the formatting inside the panel, which mounts only on open. That keeps every
  locale/timezone dependent string out of the server render, so hydration has
  nothing to disagree about.
- One body component serves both surfaces, switched by an `inverted` flag: on the
  tooltip's inverted surface text-muted-foreground has no contrast, so row labels
  drop to opacity-70 there and to text-muted-foreground in the popover.

Behavior — overlay on images
- overlay pins the chip absolutely into a corner of the nearest positioned
  ancestor (document that the image wrapper needs `relative`), inset 2 units, with
  a small z-index.
- Over an arbitrary photo no accent can be trusted, so overlay REPLACES the tone
  surface with a token scrim: bg-background/80 + backdrop-blur + border +
  text-foreground. Contrast then comes from the theme's own background/foreground
  pair whatever the pixels underneath do; the accent survives on the glyph only.
- Portalling the panel is what lets it escape the image wrapper's overflow-hidden;
  an in-place panel would be clipped by the very rounding that makes the card.

Behavior — one-shot flash
- flash sweeps a single translucent band across the chip when it mounts. The lock
  is structural: a CSS animation starts only when the node is inserted, so no
  re-render, theme flip or panel open can replay it, and there is no timer to
  leak. Use animation-fill-mode forwards so the band parks off the right edge,
  then remove the node in onAnimationEnd.
- Under prefers-reduced-motion the band is display:none, the animationend event
  never fires, the node stays hidden and inert, and the chip is simply static —
  nothing else about it changes.
- Replaying is a mount concern: the consumer gives the chip a new key.
- Ship the keyframes with the component through a React 19 hoisted <style href
  precedence>, so instances dedupe and no Tailwind config edit is needed.

Behavior — edge cases
- showLabel={false} together with icon={null} would be an empty capsule: fall back
  to showing the label.
- While the label is hidden it still renders in an sr-only span, so the accessible
  name is the full claim and never just a glyph.
- The static chip must NOT be select-none: a provenance label has to travel with
  the paragraph when a reader copies it.

Rendering & styling
- Semantic tokens only: var(--chart-1|2|4) for the three claims, bg-background/80,
  text-foreground, text-muted-foreground, bg-popover, text-popover-foreground,
  border, ring, text-primary for a footer link. Tints are
  color-mix(in oklab, <token> 14%, transparent) for soft and 45% for the outline
  border — no hex, no rgb(), no oklch() literal.
- Capsule: inline-flex, w-fit, rounded-full, overflow-hidden (it clips the sweep),
  align-middle so it sits on the text baseline inside prose, whitespace-nowrap,
  and a truncating label span so a max-width from className clips instead of
  wrapping.
- Interactive state: cursor-pointer, hover ring-2 ring-ring/30, focus-visible
  ring-2 ring-ring, and an open-state ring driven by the primitive's data-state.
  Transitions carry motion-reduce:transition-none; the panel's enter/exit
  animation carries motion-reduce:animate-none.
- Expose data-provenance and data-size on the root so host CSS and tests can hook
  the chip without prop drilling.

Customization levers
- The provenance table is the main lever: rename the labels for your locale,
  repoint the colors at other tokens (brand var(--primary) for "generated",
  var(--muted-foreground) for a deliberately quiet one), or extend the union with
  a fourth row such as "translated" — the union type is the only place to edit.
- Row labels ("Model", "Generated") are literals on purpose; for another language
  skip model/date and pass everything through `fields`, which is rendered in
  order and takes any ReactNode value.
- Density: sizes are three rows in one table — change heights, paddings and glyph
  sizes there rather than per call site. sm + showLabel={false} is the variant
  built for image corners and dense tables.
- Disclosure: force details="tooltip" for a purely informational chip, "popover"
  when the panel needs a link or a copy button, "none" to keep the chip decorative
  while metadata lives elsewhere on the page.
- Overlay scrim: raise bg-background/80 toward /95 for busy photos, drop the
  backdrop-blur for cheaper paints on long grids, or move the inset from 2 to 3 if
  your images have rounded corners bigger than the chip.
- Motion: flash is off by default; keep it for the moment content finishes
  generating and leave it off in lists, where a dozen sweeps at once is noise.
- Panel width (w-64) and side/align are the only geometry knobs; leave
  collisionPadding on so a chip near the viewport edge flips instead of overflowing.

Concepts

  • Affordance follows content — the chip is a <span> until there is something behind it; passing a model, a date, extra fields or a footer is what promotes it to a focusable button. Nothing renders a cursor, a ring or a tab stop that would open an empty panel.
  • Tooltip vs popover split — a tooltip is a passive hover/focus reveal, so it carries read-only rows and nothing else; the moment the panel needs a link or a button the component switches to a popover, the only surface a pointer, a touch and Esc can all address.
  • Token scrim over pixels — an overlay chip cannot know what is under it, so it stops tinting itself with the accent and paints the theme's own background at 80% with a blur behind it; contrast becomes a property of the theme instead of a bet on the photo.
  • Portal beats the clip — the image wrapper that gives the picture its rounded corners is also an overflow-hidden box, which would slice an in-place panel; portalling the popover to the document lets a corner badge open a full-width panel from inside a 40px-tall thumbnail.
  • Insert-time one-shot — the arrival sweep is locked by DOM insertion rather than by a ref or a timer: CSS only starts an animation when the node appears, so re-renders and theme flips cannot replay it, and remounting with a new key is the deliberate way to play it again.
  • Panel-time formatting — dates are turned into text inside the panel, which only exists after the reader opens it, so a server rendered in UTC and a browser in Asia/Shanghai never produce two different strings for the same instant.

On This Page