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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ai-badge.jsonPrompt
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-hiddenbox, 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.
AI Disclaimer
The 'AI can make mistakes. Check important info.' line — inline, banner and details-popover chromes, role=note so it never interrupts, and a one-shot dismissal that hands persistence to the app.
Stop Generating
One button slot for the whole run — Stop while tokens arrive, Continue generating when the reply hit its length cap, nothing when idle, with a scoped Esc binding and a one-shot lock on both actions.