Browse all components
Citation Popover UI
A citation marker that shows the passage it came from — fixed positioning that survives a scrolling answer, touch and keyboard, and an honest state for a number the model invented.
Preview
Retrieval keeps the offsets rather than the text, so a citation resolves to a range in the original document instead of to a filename — which is the difference between a source list at the bottom and a claim you can check.
Presets
Props
Agent promptClaude Code · Cursor · any agent
Add the Citation Popover UI component from the Scrim UI registry to this project and use it in the configuration described below.
Citation Popover UI — A citation marker that shows the passage it came from — fixed positioning that survives a scrolling answer, touch and keyboard, and an honest state for a number the model invented.
## 1. Install
```bash
npx shadcn@latest add @scrimui/citation-popover
```
This writes a single file to `components/ui/citation-popover.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/citation-popover to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { CitationPopover } from "@/components/ui/citation-popover";
<CitationPopover
n={1}
passage={passage}
source="chunking.md · lines 12–18"
/>
```
## 3. The configuration that matters
This is the component's "Resolved" state — The ordinary case — a marker with a passage behind it, previewed on hover, tap or focus.
These props were set deliberately and should be preserved as written:
- `score` = 0 — Score (0 hides it)
Every other prop is at its default; leave those off the call site rather than writing the default value out.
Reference: https://scrimui.dev/components/citation-popoverFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/citation-popoverComponent source
React + Tailwind component using shadcn/ui (button). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button"use client";
import { Button } from "@/components/ui/button";
import * as React from "react";
/**
* A citation marker, and the passage behind it.
*
* Small component, four problems in it, and only the first is the one people
* expect.
*
* **Positioning.** The popover cannot live inside the answer's flow: the
* answer scrolls, and an absolutely-positioned panel inside a scroll
* container gets clipped by it. So the panel is `position: fixed`, measured
* from the chip's rect when it opens, and clamped to the viewport — flipping
* above the chip when there is no room below, and sliding sideways rather
* than hanging off the edge. Measured on open rather than tracked
* continuously; a citation popover that follows the text as it scrolls is
* doing work nobody asked for.
*
* **Touch.** Hover does not exist on a phone, and a hover-only affordance is
* one the reader will never find. So the chip is a real `<button>` that
* toggles on tap, and hover is an addition for pointers that have it — not
* the mechanism. `onPointerEnter` rather than `onMouseEnter`, because the
* synthetic mouse events a tap produces would otherwise open the panel and
* the tap would immediately close it again.
*
* **Keyboard.** Focus opens it, Escape closes it, and Enter jumps to the
* passage in the document — the same thing a click does. A citation only a
* mouse can reach is a footnote you have hidden.
*
* **The number the model invented.** `passage` is optional because a model
* asked to cite only what it was given will still, sometimes, write `[7]`
* when it was given six passages. That case is the reason this component
* takes a passage rather than a passage id: an unresolved marker renders as
* struck-through plain text, so the claim survives and the promise of a
* source does not. Rendering an empty popover instead is how a hallucinated
* citation gets laundered into a real-looking one.
*
* The chip renders the same characters the model wrote. Not a superscript,
* not an icon: the number is what the reader matches against the passage
* list, and replacing it with a symbol breaks that match for the sake of
* looking tidier.
*/
export type CitationPopoverProps = {
/** The number the model wrote — and the passage's own number. */
n: number;
/**
* The passage text. Slice it out of the document by the offsets you carried
* through retrieval; do not send it alongside them and hope the two agree.
* Undefined means "no passage with that number", which is a state, not a
* missing prop.
*/
passage?: string;
/** Where the passage came from — file name, page, section. */
source?: string;
/** Cosine similarity, if you want it shown. Useful while tuning the floor. */
score?: number;
/** Jump to it in the document. Undefined leaves the chip a preview-only
* affordance, which is the right behaviour when there is no document pane
* to jump into. */
onJump?: () => void;
};
export function CitationPopover({ n, passage, source, score, onJump }: CitationPopoverProps) {
const [open, setOpen] = React.useState(false);
const [position, setPosition] = React.useState<{ left: number; top: number; above: boolean }>();
const chipRef = React.useRef<HTMLButtonElement>(null);
const resolved = passage !== undefined;
const place = React.useCallback(() => {
const el = chipRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const width = Math.min(340, window.innerWidth - 24);
/* Guessing the height before it renders would flip wrongly on a long
passage; 240 is measured against the panel's own cap below, so the flip
decision is made against the worst case rather than the average. */
const below = window.innerHeight - rect.bottom;
const above = below < 240 && rect.top > below;
setPosition({
left: clamp(rect.left + rect.width / 2 - width / 2, 12, window.innerWidth - width - 12),
top: above ? rect.top - 8 : rect.bottom + 8,
above,
});
}, []);
const show = React.useCallback(() => {
if (!resolved) return;
place();
setOpen(true);
}, [place, resolved]);
/* Closing on scroll rather than repositioning: the panel was opened from a
rect that is no longer where it was, and following it around a scrolling
answer is motion the reader did not ask for. Capture phase, because the
scroll that moved the chip is usually an ancestor's, not the window's. */
React.useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [open]);
if (!resolved) {
return (
<span
className="mx-0.5 align-baseline text-[0.85em] text-muted-foreground line-through"
title={`The answer cited [${n}], but no passage with that number was retrieved.`}
>
[{n}]
</span>
);
}
return (
<>
<Button variant="ghost" size="icon-sm"
ref={chipRef}
type="button"
onClick={() => (onJump ? onJump() : setOpen((v) => !v))}
onPointerEnter={(e) => e.pointerType === "mouse" && show()}
onPointerLeave={(e) => e.pointerType === "mouse" && setOpen(false)}
onFocus={show}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape" && open) {
/* stopPropagation so a dialog or drawer wrapping the answer does
not also close on the same key. Dismissing the popover is what
the reader meant; closing the thing they were reading is not. */
e.stopPropagation();
setOpen(false);
}
}}
aria-expanded={open}
aria-label={`Source ${n}${onJump ? " — jump to it in the document" : ""}`}
className="min-h-6 min-w-6 mx-0.5 inline-flex h-[1.35em] min-w-[1.35em] items-center justify-center rounded-[0.3em] bg-amber-100 px-[0.3em] align-[-0.1em] text-[0.75em] font-medium text-amber-900 tabular-nums transition-colors hover:bg-amber-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-amber-500 dark:bg-amber-400/20 dark:text-amber-200 dark:hover:bg-amber-400/30"
>
{n}
</Button>
{open && position && (
<span
role="tooltip"
/* pointer-events-none: the panel is a preview, and a preview the
pointer can enter is a preview that has to solve the gap between
the chip and itself. It cannot be hovered, so there is no gap. */
className="pointer-events-none fixed z-50 block rounded-lg border border-border bg-card p-3 shadow-md"
style={{
left: position.left,
top: position.top,
width: "min(340px, calc(100vw - 24px))",
transform: position.above ? "translateY(-100%)" : undefined,
}}
>
<span className="mb-1.5 flex items-center justify-between gap-3 text-xs font-medium text-muted-foreground">
<span className="min-w-0 truncate">{source ?? `Source ${n}`}</span>
{score !== undefined && <span className="shrink-0 tabular-nums">{score.toFixed(3)}</span>}
</span>
{/* Capped, and scrolling is not an option on a pointer-events:none
panel — a passage longer than this is a sign the chunk size is
too big, which is a retrieval problem rather than a UI one. */}
<span className="block max-h-[180px] overflow-hidden text-xs leading-5 text-muted-foreground">
{passage}
</span>
{onJump && (
<span className="mt-2 block text-xs text-muted-foreground">
Click to jump to it in the document
</span>
)}
</span>
)}
</>
);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max));
}
When to use it
- Pass the passage, not a passage id. The component's job is to show the text the claim came from, and a lookup by id is one more place for the answer and the sources to disagree.
- Slice the passage out of the document with the offsets you carried through retrieval. Storing the text alongside the offsets means the two can drift, and the drift is invisible until someone clicks.
- Leave `passage` undefined for a number that was not retrieved. It is a real outcome, not a loading state — the model will occasionally cite a passage it was never given.
- Send the sources down the stream before the first token, so a marker resolves the moment it arrives rather than after the answer settles.
- Give `onJump` a destination only when there is a document pane on screen. Without one, the popover is the whole affordance and a click that goes nowhere is worse than no click.
What breaks in production
- Positioning the panel absolutely inside the answer. The answer is a scroll container, and the panel gets clipped by it the first time a citation lands near the bottom.
- Opening on hover only. On a phone there is no hover, and the citation becomes text nobody can check.
- Repositioning the panel as the page scrolls. It was measured from a rect that has moved; closing is what the reader expected, following the text around is motion they did not ask for.
- Rendering an empty popover for an unresolved number. That launders a hallucinated citation into a real-looking one — the state the reader most needs to see becomes the state that looks most normal.
- Replacing the number with an icon or a superscript glyph. The number is what the reader matches against the source list, and it is the same character the model wrote.
Related Components
A cited source as a card — favicon, page title, domain, and the snippet the answer actually drew from.
The passages retrieval actually returned, with similarity scores and a visible relevance floor — including the case where nothing cleared it.
Inline citation markers in generated text — numbered footnotes that reveal the source in a hover preview card.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.