AI

Citation Chip

The inline [n] marker that trails a sentence in an AI answer — an inline box that cannot change the paragraph's line height, with a source card portalled past every clipping bubble.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { ExternalLink, FileText, Globe, Link2Off, Lock } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entry animation ships with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so a page with forty chips still emits one rule
 * set. One keyframe per side — the card always slides away from its marker.
 */
const KEYFRAMES = `@keyframes zg-cite-in-top{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
@keyframes zg-cite-in-bottom{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}`

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "CitationChip" component (react-dom
createPortal + lucide-react icons; no other dependencies) — the inline [n]
marker that trails a sentence in an AI answer, plus the source card behind it.

Contract
- Two exports. `CitationScope({ sources, highlight, children })` declares the
  numbered source set for one assistant message;
  `CitationChip = forwardRef<HTMLElement, CitationChipProps>` renders one
  marker inside the prose.
- CitationSource = { id: string; title: string; url?: string; domain?: string;
  excerpt?: string; kind?: "web" | "file" | "internal"; unavailable?: boolean }.
- CitationChipProps extends Omit<React.HTMLAttributes<HTMLElement>, "id" |
  "color"> and adds sourceId (matches a CitationSource.id), children (the
  tether, see Behavior), openDelay = 140, closeDelay = 180, side: "top" |
  "bottom" = "bottom", width = 288, plus target / rel which are forwarded to
  the marker anchor AND to the card's link.
- NUMBERING BELONGS TO THE SCOPE, NOT THE CALLER AND NOT THE CHIP. The number
  is the source's position among the UNIQUE ids in `sources`, in first-appearance
  order — not `index + 1`, which would leave holes wherever a source repeats
  (`[a,b,a,c]` numbers `c` as 3, not 4). Computed once in a useMemo. That is
  the only choice that makes all three of these agree without extra
  bookkeeping: a source cited five times shows the same number five times, a
  reference list rendered from the same array matches the markers, and the
  numbering is identical on the server and after hydration (render-order
  counters are not — a discarded or re-ordered render consumes numbers).
  A duplicate id in `sources` keeps the number it was first given.
- A chip whose sourceId is not in the scope renders null. A citation to a
  source you do not have is not a citation; it must not become a [?] in the
  prose.

Behavior — inline layout (the constraint that shapes everything)
- The marker sits inside running prose, so EVERY box it renders is
  `display: inline`. Two properties follow, and both matter:
  (a) an inline non-replaced box's padding and border paint without
  contributing to layout, so with `leading-none` the marker's own line box is
  never taller than the paragraph's and inserting markers cannot change the
  line height;
  (b) an inline boundary is not a soft wrap opportunity, so a marker written
  flush against its word (`…overnight<CitationChip …/>`) cannot be orphaned
  onto the next line.
- Do NOT use inline-block / inline-flex, and do NOT use a <button> for the
  link-less variant: browsers force `display: inline-block` on <button>, which
  makes it an atomic inline — a real break opportunity and a real line-box
  contributor. Measured in Chromium: an inline-block marker orphaned itself
  from its word at 55 of 481 container widths. The link-less marker is a
  `<span role="button" tabIndex={0}>` with an Enter/Space handler instead.
- When your text puts a SPACE before the marker, the break opportunity lives in
  the consumer's own text and CSS on the marker cannot reach it. Pass the
  preceding word(s) as `children`: they render inside the marker's
  `white-space: nowrap` wrapper so the pair wraps as one unit. Measured in
  Chromium: a U+2060 WORD JOINER does NOT rescue the spaced case — the break
  after a space happens regardless — so do not reach for it; the nowrap tether
  is the thing that holds.

Behavior — the card
- The card is portalled into document.body with `position: fixed`. Markers live
  in message bubbles, and a bubble is almost always `overflow: hidden` (often
  `position: relative` too), which would clip an in-flow card into
  invisibility. Measured with the portal removed: 0% of the card remained
  inside the bubble box and 0 of 5 hit-test probes reached it.
- The boundary the card is flipped and clamped against is the viewport
  intersected with every `overflow: auto | scroll` ancestor — and NOT the
  `overflow: hidden` ones. Those are what the portal escapes; treating them as
  a boundary would "fix" the clipping by crushing the card instead. A
  scrollable ancestor is different: it is a real viewport for the marker, so
  the card must stay inside it.
- One synchronous layout pass: measure the anchor rect and the card's natural
  height (with our own max-height momentarily lifted, so a flip is never
  decided from an already-capped box), flip to the other side only when it is
  genuinely roomier, cap the height to the space available, centre on the
  marker, then clamp into the boundary. Re-run it from a ResizeObserver on both
  anchor and card plus capturing `scroll` and `resize` listeners, all of them
  released on close. Guard the state setter with a layout-equality check, or
  the observer re-fires on the max-height you just applied and loops.
- Until that first measurement lands the card has no coordinates, so it renders
  with `opacity-0 pointer-events-none` — never `invisible`. visibility:hidden
  also makes the subtree unfocusable, so the link inside would silently refuse
  focus. Measured with visibility:hidden instead: one frame at opacity 1 sitting
  at the fallback 0,0 position.
- Open state is derived. Track hoveredTrigger / hoveredPanel / focused, run ONE
  timer that commits `!dismissed && (hovered || focused)` after openDelay or
  closeDelay, and OR it with a separate `pinned` boolean for click-opened cards.
  The positioner's offset is padding, not margin, so the pointer can travel
  from marker to card without a mouse-leave — that is the difference from a
  tooltip.
- Touch: `matchMedia("(pointer: coarse)")`. On a coarse pointer an unmodified
  tap preventDefaults and toggles the card instead of navigating, because there
  is no hover to express intent and a tap that just leaves the page is a
  citation you can never read. The card then carries the real link. Modifier
  and middle clicks are never intercepted, so ⌘-click and "open in new tab"
  keep working, and on a fine pointer the marker is an ordinary link.
- Escape closes, registered on document in the CAPTURE phase with
  stopPropagation, because the card is the innermost layer and is routinely
  open while focus is somewhere else entirely. Clicking outside unpins.

Behavior — sources that cannot be linked
- Resolve the href defensively: trim it, treat "" and "#" as absent, allow
  relative paths, and otherwise accept only http/https/mailto. Citation
  payloads come out of a retrieval pipeline or a model, so a `javascript:` URL
  is a live possibility — it must never reach the DOM.
- `unavailable: true` (a 404, an expired share link, a paywalled page) and a
  missing url both downgrade the marker to the role="button" toggle. Never
  render a dead anchor. The card's header line carries the domain or, when
  there is none, the kind ("Uploaded file", "Internal document"), and a note row
  answers the only remaining question — "Link unavailable" / "No link
  available" — plus the original URL as plain selectable text when there was
  one.
- `kind` picks the glyph and the wording: Globe / FileText / Lock, Link2Off
  when unavailable.

Behavior — excerpt highlighting
- `highlight` (a string or string[]) marks terms inside every excerpt. Split
  the excerpt with one capturing group built from the escaped terms, longest
  first, and wrap the odd indices in <mark>. Structural, never
  dangerouslySetInnerHTML: an excerpt reading `<img src=x onerror=alert(1)>`
  has to render those characters as text, not create an element.

Rendering & styling
- Marker: inline, whitespace-nowrap, rounded-sm border bg-muted, px-[0.35em]
  py-[0.05em], text-[0.72em] font-medium leading-none text-muted-foreground,
  no-underline, align-baseline; hover and data-[state=open] go to bg-primary/10
  + text-foreground; focus-visible:ring-2 ring-ring. The em-relative padding
  and font-size mean the marker scales with whatever prose it is dropped into.
- Card: rounded-lg border bg-popover text-popover-foreground shadow-lg, a
  header row (kind glyph + domain + the number), the title, a line-clamp-4
  excerpt with <mark> on bg-primary/15, and either an "Open source" link
  (text-primary + ExternalLink) or the muted no-link note.
- Entry animation is one keyframe per side, emitted through React 19's
  <style href precedence> so N markers on a page still emit one rule set, and
  switched off under motion-reduce — the card still opens, flips and closes.
- Accessibility: the marker's aria-label carries the whole citation ("Citation
  3: <title>, <domain>" plus the reason when there is no link), and its
  aria-describedby points at an sr-only copy of the excerpt, so a screen reader
  never needs the card at all. The card is role="dialog" with an aria-label,
  NOT role="tooltip" — it can contain a link, and tooltip semantics would
  flatten it. aria-haspopup / aria-expanded / aria-controls are set only on the
  variants where activating the marker actually toggles the card (link-less, or
  coarse pointer); advertising them on a marker whose click navigates would be
  a lie.
- Semantic tokens only: bg-muted, bg-popover, bg-primary/10, bg-primary/15,
  text-muted-foreground, text-popover-foreground, text-foreground, text-primary,
  border, ring-ring. No hex/rgb/oklch.

Customization levers
- Marker shape: it is a pill by default; `[{number}]` brackets, a superscript
  (add `align-super` and drop the border), or a coloured dot are all
  className-level changes — keep `display: inline` and `leading-none` or the
  line-height guarantee goes with them.
- Card anatomy: domain row, excerpt and link row are independently optional.
  Add a favicon next to the domain, a relevance score, a "quoted 3×" count or a
  "copy citation" button without touching the open/close machinery; anything
  focusable you add is reachable because the card is not focus-trapped.
- Timing: openDelay is the hover-intent knob (120-300ms reads well); closeDelay
  only has to cover the pointer's travel across the padding bridge — raise both
  together if you widen it.
- side="top" for markers near the bottom of a scroll container; `width` for
  denser or roomier cards. Both are per-marker.
- Numbering: swap the scope's index-based map for your own ordering (first
  appearance in the text, group by domain) — it is one useMemo, and every
  marker follows automatically.
- Reference list: render the same `sources` array under the answer and the
  numbers line up for free; Citation List is built for exactly that.

Concepts

  • Scope-ordered numbering — the number is the source's position among the unique ids in the scope's sources array, in first-appearance order — not index + 1 (which would leave a hole wherever a source repeats) and not a render-order counter. Repeated citations therefore merge onto one number by construction, the reference list below the answer agrees with the markers for free, and server and client render the same digits.
  • Line-box-safe inline box — an inline non-replaced element's padding and border paint outside its line-box contribution, so with leading-none the marker cannot make a line taller. This is why the link-less variant is a role="button" span: browsers force display: inline-block on <button>, which turns it back into an atomic inline that both breaks lines and grows them.
  • Nowrap tether — when the prose puts a space before the marker, the wrap opportunity belongs to the consumer's text, and children is the hook that lets the component swallow it into a white-space: nowrap box. A U+2060 WORD JOINER does not work here: Chromium breaks after the space regardless.
  • Portal past the bubble — the card is rendered into document.body as position: fixed, because the message bubble it is quoted in is almost always overflow: hidden. Clipping ancestors are escaped rather than obeyed; only overflow: auto | scroll ancestors count as a boundary, since those are real viewports for the marker.
  • Measure-then-paint — the card is opacity-0 pointer-events-none until the first ResizeObserver callback has flipped, capped and clamped it. visibility: hidden would hide the same frame but also make the card unfocusable, which quietly kills every keyboard path through it.
  • Two-stage touch — on a coarse pointer the first tap opens the card instead of following the link, because there is no hover to express "show me" and a tap that navigates away is a citation nobody can read; the card then carries the real link, and modifier clicks are never intercepted.
  • Honest absence — an internal page, an uploaded file or a dead URL downgrades the marker from an anchor to a toggle, and the card states the reason instead of offering a link that goes nowhere. javascript: and # hrefs are rejected before they reach the DOM.

On This Page