AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ExternalLink, Info, X } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * AI Disclaimer
 *
 * The "AI can make mistakes. Check important info." line. Three chromes for the
 * three places it lives — under a composer (inline), above a thread (banner),
 * and next to a disclosure that explains WHY plus where the data goes
 * (details).
 *

Installation

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

Prompt

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 it role="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 the dismissed prop 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 getBoundingClientRect read 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.

On This Page