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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/citation-chip.jsonPrompt
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
sourcesarray, in first-appearance order — notindex + 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-nonethe marker cannot make a line taller. This is why the link-less variant is arole="button"span: browsers forcedisplay: inline-blockon<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
childrenis the hook that lets the component swallow it into awhite-space: nowrapbox. AU+2060WORD JOINER does not work here: Chromium breaks after the space regardless. - Portal past the bubble — the card is rendered into
document.bodyasposition: fixed, because the message bubble it is quoted in is almost alwaysoverflow: hidden. Clipping ancestors are escaped rather than obeyed; onlyoverflow: auto | scrollancestors count as a boundary, since those are real viewports for the marker. - Measure-then-paint — the card is
opacity-0 pointer-events-noneuntil the firstResizeObservercallback has flipped, capped and clamped it.visibility: hiddenwould 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.
Tool Call Card
One agent tool call inline in the conversation — a first-class approval gate with one-shot Approve/Decline, structured args and results, key-based redaction that never puts the secret in the DOM, announced truncation for huge payloads, and four data states.
Suggested Prompts
Click-to-send conversation starters and follow-ups — a wrapping strip of one-shot cards that latches after the first pick, goes inert while the assistant streams, and re-rolls without collapsing the row.