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…
- Dependencies
- lucide-react, radix-ui
- Registry dependencies
- tooltip, utils
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 is controlled (open / onOpenChange) so a click or tap toggles it:
Radix never opens a tooltip for a touch pointer and closes it on pointerdown
and on click. Record the open state in the trigger's onPointerDown (it runs
before Radix's close), then in onClick call preventDefault (which skips
Radix's close-on-click) and set open to the opposite of that recorded state.
A keyboard click (event.detail === 0) toggles from the current state and
ignores the recorded one, which is stale after a press that never became a
click (dragged off, or a touch that turned into a scroll). A
consumer onClick that calls preventDefault opts out of the toggle. Hover and
focus still open it, tapping outside dismisses it. 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.
- The panel's heading glyph is the chip's resolved glyph: a custom `icon`
appears in the panel too, and `icon={null}` leaves the panel without one, so
chip and panel never disagree.
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.