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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ai-disclaimer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AiDisclaimer" component: the standing
"AI can make mistakes. Check important info." line that sits under a composer,
above a thread, or next to a disclosure that explains why. Dependencies:
lucide-react for glyphs and a cn() class merger. Do NOT pull in a popover
library — the disclosure is hand-rolled so placement and focus return are
explicit and auditable.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>;
remaining props spread onto the root. `id` matters: the root is usually the
composer's aria-describedby target.
- children?: ReactNode — the sentence itself. Defaults to
"AI can make mistakes. Check important info."
- variant?: "inline" | "banner" | "details" (default "inline").
- icon?: ReactNode — `undefined` means the per-variant default (only "banner"
has one: a size-3.5 Info glyph), `null` means no icon at all.
- dismissible?: boolean (default false) — renders the dismiss button.
- dismissed?: boolean — controlled visibility.
defaultDismissed?: boolean (default false) — uncontrolled seed, hydrate it
from whatever store onDismiss wrote to.
- onDismiss?: () => void — the persistence hook; fires at most once per visible
lifetime.
- dismissLabel?: string (default "Dismiss") — accessible name of the button.
- detailsLabel?: string (default "Why?"), detailsTitle?: string (default
"About this assistant"), reasons?: readonly string[], links?: readonly
{ label: string; href: string }[], onDetailsOpenChange?: (open: boolean) =>
void — read ONLY by variant="details"; the other variants ignore them, and
the JSDoc must say so.
- The reasons/links defaults are shared module-level empty arrays, so a caller
that omits them never changes identity between renders.
Behavior — visibility and the one-shot dismiss lock
- hidden = dismissed ?? internal state seeded with defaultDismissed. When
hidden the component returns null but stays mounted, so its hooks keep
running and the lock below can reset.
- On dismiss: if a ref-held "persisted" flag is set, return; otherwise set it,
hide (only when uncontrolled), then call onDismiss. A controlled app usually
persists asynchronously — write, then flip the prop — which leaves the button
live for a beat; the lock is what makes an impatient double-click write once.
- An effect clears the lock whenever the component becomes visible again, so a
"reset preferences" flow can be dismissed a second time.
- The component never decides whether it should be shown: storage, session
scope and re-show policy belong to the app. It also never animates itself
away — dismissal is instant, because the user asked for it to be gone.
Behavior — the details disclosure
- Render the trigger ONLY when reasons.length + links.length > 0. An empty
popover is worse than no affordance.
- Trigger: <button type="button" aria-haspopup="dialog" aria-expanded
aria-controls={open ? panelId : undefined}> with aria-label
`${detailsLabel} — ${detailsTitle}` — it starts with the visible text so
speech control still matches "Why?", and continues with what the panel is
about, which "Why?" alone never says.
- Panel: role="dialog", aria-labelledby the title, tabIndex={-1}, focused with
focus({ preventScroll: true }) on open. It is NOT modal: no focus trap, no
scroll lock, no overlay. A disclaimer must never take the page hostage.
- Placement is measured, not guessed (the line usually sits at the very bottom
of a chat panel, where "below" has no room). In a layout effect — falling
back to useEffect on the server — read the trigger's getBoundingClientRect
and the panel's offset box: open below unless there is no room below AND
there is room above; align left unless that overflows the right edge AND
right-aligning fits. Keep a 12px viewport gap. Re-measure on window resize
and on scroll in the CAPTURE phase so scrolling any ancestor container keeps
the panel honest, and remove both listeners on close and on unmount.
- Closing paths, all three attached only while open (`if (!open) return`), all
three cleaned up by the same effect:
1. Escape — close, then move focus back to the trigger; Escape is an explicit
"take me back".
2. pointerdown outside the wrapper — listened in the capture phase so a
consumer's own stopPropagation cannot strand the panel open.
3. focus leaving the subtree — React onBlur on the wrapper with a
relatedTarget containment check; this one leaves focus where the user
sent it, because pulling it back would fight the Tab key.
- onDetailsOpenChange fires on both edges. Read it through a ref so the
open/close callbacks stay referentially stable and the document listeners are
attached exactly once per open.
- Links: an http(s) href gets target="_blank" rel="noopener noreferrer" and a
trailing external glyph; anything else navigates in place. Destinations are
consumer-owned — never invent one, and never render a link with nowhere to go.
Rendering & styling
- Semantic tokens only: text-muted-foreground for the whole line, bg-muted/40 +
border for the banner, bg-popover / text-popover-foreground / border /
shadow-md for the panel, text-primary for panel links, ring-ring for every
focus-visible ring, bg-muted-foreground for the bullet dots. No hex, no
rgb(), no oklch(), no accent fill — muted is the design, not an oversight.
- The root is role="note": a standing statement should be reachable and
quotable but must never interrupt. Deliberately NOT role="status" or "alert",
and there is no aria-live anywhere — otherwise the line re-announces itself
over the answer the user actually asked for.
- Layout per variant: inline = centred flex row, gap-1.5, text-xs, leading-snug;
details = the same plus flex-wrap, so the trigger drops to its own line in a
narrow column instead of squeezing the sentence; banner = rounded-lg border
bg-muted/40 px-3 py-2, message on flex-1, dismiss button pinned to the end.
- Icons are aria-hidden and shrink-0; the message span is min-w-0 so a long
sentence wraps instead of pushing the dismiss button out of the row.
- The panel's enter animation is two keyframes (fade + 4px slide, direction
chosen from the resolved side) shipped through a React 19 hoisted
<style href precedence> element — no Tailwind config edits, duplicate
instances dedupe by href — and switched off with
motion-reduce:[animation:none]. There is no exit animation: the panel
unmounts, which is what a dismissal should feel like.
- Expose data-variant on the root and data-state / data-side / data-align on
the disclosure, so an app can hook its own styling without forking the file.
Customization levers
- Copy: children is the entire sentence — localise it, or replace it with a
domain line ("Not legal advice", "Verify dosages against the label") and keep
the chrome.
- Density: the root's text-xs / leading-snug / gap-1.5 and the banner's px-3
py-2 are the only spacing knobs; override them through className, cn() gives
the caller the last word.
- Loudness: for a regulated surface swap bg-muted/40 for bg-primary/5 +
border-primary/20, or drop the border for the quietest possible strip. Keep
the text muted whatever you do — a disclaimer that shouts gets dismissed.
- Icon: any lucide glyph at size-3.5 matches the type; pass null to strip it, or
move the per-variant default table to give inline an icon too.
- Disclosure content: reasons is a plain string list, so it can come straight
from a policy copy deck; links is a slot list, so a model card, a DPA and a
support form all fit. Add a footer slot by rendering one more child after the
links block.
- Placement maths: the 12px viewport gap and the w-72 panel width are the two
numbers that decide flipping — widen the panel and it flips sooner.
- Dismissal scope: uncontrolled (defaultDismissed from localStorage) for a
per-device preference, controlled (dismissed) when the preference lives on
the server; leave dismissible off entirely where policy says the line stays.
- Analytics: onDetailsOpenChange is the "did anyone actually read it" signal;
pair it with onDismiss to see how fast the line gets turned off.Concepts
- Passive by construction — the line is a standing statement, so it lives in a
role="note"with no live region at all; making itrole="status"would re-announce "AI can make mistakes" over the answer the user asked for, every single render. - One-shot dismissal lock — the dismiss handler flips a ref before it calls
onDismiss, so an app that persists asynchronously (write to the server, then flip the prop) still records exactly one preference write no matter how impatiently the button is clicked; the lock resets the moment the line becomes visible again. - Consumer-owned persistence — the component either hides itself (uncontrolled, seeded by
defaultDismissed) or waits for thedismissedprop to say so (controlled); it never touches storage, never guesses session scope, and never decides when to come back. - Disclosure over dumping — the "why" copy and the data-usage links stay behind one trigger that only exists when there is something to open, so the quiet line stays quiet and the compliance text is one keystroke away instead of pasted into the chrome.
- Flip on measure — the panel's side and alignment come from a
getBoundingClientRectread before paint, re-run on resize and on capture-phase scroll, because the disclaimer's natural home is the bottom edge of a panel where "open below" is never right. - Focus return contract — Escape is an explicit "take me back" and returns focus to the trigger, while a click outside or a Tab past the last link closes the panel and leaves focus exactly where the user sent it.
Translation Toggle
A per-message translation affordance — a pill that names the detected source language, crossfades between original and translation while the box tweens between their measured heights, re-aims at another target language from a menu, and stands in with a skeleton shaped by the source while a request is in flight.
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.