Citation
An inline numbered source that previews the site, title and snippet on hover or focus.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/citation.jsonOverview
Answers that cite sources earn trust, but a wall of links at the bottom gets ignored. This puts a small numbered marker right in the sentence. Hover or focus it and a card rises out of a light blur to show where the claim comes from, then sinks back when you leave; click and the source opens. The card flips above near the bottom of the screen and shifts to stay on it, and a source that couldn’t be fetched says so instead of failing quietly.
States
- closed
- A quiet numbered marker that sits on the text’s baseline.
- open
- After a short hover delay, or at once on focus: site, title and a snippet. The card rises 4px out of a 2px blur (220ms) and sinks back the same way when it closes (140ms).
- unavailable
- The marker is struck through and the card says the source couldn’t be loaded.
Usage
import { Citation } from "@/components/citation";
<p>
Highlights style text without touching the DOM
<Citation index={1} source={{ title: "CSS Custom Highlight API", url: "https://developer.mozilla.org/…", snippet: "…" }} />
</p>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
With the AI SDK
Map the Vercel AI SDK’s message parts and chat status onto the component’s props.
// app/api/chat/route.ts: send search results to the client as sources
return result.toUIMessageStreamResponse({ sendSources: true });
// The answer: sources arrive as "source-url" parts; swap each [n] the model writes for a Citation
import type { UIMessage } from "ai";
import { Citation } from "@/components/citation";
function Answer({ message }: { message: UIMessage }) {
const sources = message.parts.filter((part) => part.type === "source-url");
const text = message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
return (
<p>
{text.split(/\[(\d+)\]/).map((chunk, i) => {
if (i % 2 === 0) return chunk;
const source = sources[Number(chunk) - 1];
return (
<Citation
key={i}
index={Number(chunk)}
source={source && { title: source.title ?? source.url, url: source.url }}
/>
);
})}
</p>
);
}Props
| Prop | Type | Description |
|---|---|---|
| index | number | The number shown in the marker. |
| source | { title; url; snippet?; icon? } | What the card shows. icon takes a favicon element; a globe is shown without one. |
| status | "available" | "unavailable"default "available" | Unavailable (or no source) strikes the marker and explains in the card. |
Accessibility and motion
- The marker is a real link, so middle-click and Ctrl-click work, and it’s labelled "Source 1: title".
- The card is a tooltip linked with aria-describedby; it opens on keyboard focus and closes on Escape.
- Short open and close delays stop cards flickering as the pointer crosses a paragraph.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
Source
›Show the full source of citation.tsx (169 lines)
"use client";
import * as React from "react";
import { Globe } from "lucide-react";
/* ─────────────────────────────────────────────────────────
* CITATION: an inline [1] that previews its source
*
* closed a small numbered marker in the sentence
* open hover or focus: the card rises out of a light blur
* with the site, title and snippet, and sinks back
* when you leave
* unavailable the source couldn't be fetched
*
* The marker is the link itself, so clicking opens the source.
* The card is a tooltip describing it; it flips above near the
* bottom of the screen and shifts to stay inside the viewport.
* ───────────────────────────────────────────────────────── */
export interface CitationSource {
title: string;
url: string;
snippet?: string;
/** A favicon, e.g. <img src="…" alt="" />. */
icon?: React.ReactNode;
}
export interface CitationProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
index: number;
source?: CitationSource;
status?: "available" | "unavailable";
}
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
const CARD_WIDTH = 288;
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
function hostname(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
export function Citation({ index, source, status = "available", className = "", ...props }: CitationProps) {
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
// The card stays mounted while it sinks away after closing.
const [mounted, setMounted] = React.useState(false);
if (open && !mounted) setMounted(true);
const [place, setPlace] = React.useState<{ above: boolean; shift: number }>({ above: false, shift: 0 });
const triggerRef = React.useRef<HTMLAnchorElement>(null);
const cardRef = React.useRef<HTMLSpanElement>(null);
const timer = React.useRef<number | undefined>(undefined);
const id = React.useId();
const unavailable = status === "unavailable" || !source;
const show = (delay: number) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(true), delay);
};
const hide = (delay: number) => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(false), delay);
};
React.useEffect(() => () => window.clearTimeout(timer.current), []);
// Keep the card on screen: flip above near the bottom, shift left near the right edge.
React.useLayoutEffect(() => {
if (!open || !triggerRef.current || !cardRef.current) return;
const t = triggerRef.current.getBoundingClientRect();
const h = cardRef.current.offsetHeight;
const overflowRight = t.left + CARD_WIDTH - (window.innerWidth - 12);
setPlace({ above: t.bottom + h + 12 > window.innerHeight && t.top > h + 12, shift: Math.max(0, overflowRight) });
}, [open]);
// Rise in from the side it opens on; sink back the same way, then unmount.
React.useLayoutEffect(() => {
const el = cardRef.current;
if (!el) return;
const from = `translateY(${place.above ? 4 : -4}px)`;
if (open) {
if (reduced) return;
const enter = el.animate(
[
{ opacity: 0, transform: from, filter: "blur(2px)" },
{ opacity: 1, transform: "none", filter: "blur(0px)" },
],
{ duration: 220, easing: MORPH }
);
return () => enter.cancel();
}
if (reduced) {
setMounted(false);
return;
}
const leave = el.animate([{ opacity: 1, transform: "none" }, { opacity: 0, transform: from }], { duration: 140, easing: "ease-out", fill: "forwards" });
leave.onfinish = () => setMounted(false);
return () => leave.cancel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, mounted]);
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<span className={`relative inline-block align-baseline ${className}`} onMouseEnter={() => show(120)} onMouseLeave={() => hide(150)} {...props}>
<a
ref={triggerRef}
href={source?.url}
target="_blank"
rel="noreferrer"
aria-describedby={open ? id : undefined}
aria-label={`Source ${index}${source ? `: ${source.title}` : ""}`}
onFocus={() => show(0)}
onBlur={() => hide(0)}
className={`mx-0.5 inline-flex h-[1.2em] min-w-[1.2em] -translate-y-[0.12em] items-center justify-center rounded-[5px] px-1 font-mono text-[10.5px] leading-none no-underline transition-colors ${FOCUS} ${
unavailable ? "bg-muted text-muted-foreground/60 line-through" : open ? "bg-foreground text-background" : "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{index}
</a>
{mounted && (
<span
ref={cardRef}
id={id}
role="tooltip"
aria-hidden={!open || undefined}
className={`absolute z-50 block rounded-xl border border-border bg-background p-3 text-left ${place.above ? "bottom-full mb-1.5" : "top-full mt-1.5"} ${
open ? "" : "pointer-events-none"
}`}
// Shifted with left, not transform, so the entrance animation can't undo it.
style={{ width: CARD_WIDTH, left: -place.shift }}
>
{unavailable ? (
<span className="block text-[12.5px] text-muted-foreground">This source couldn’t be loaded.</span>
) : (
<>
<span className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span aria-hidden="true" className="flex size-3.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
{source.icon ?? <Globe className="size-3.5" />}
</span>
<span className="truncate">{hostname(source.url)}</span>
</span>
<span className="mt-1.5 line-clamp-2 block text-[13px] font-medium leading-snug text-foreground">{source.title}</span>
{source.snippet && <span className="mt-1 line-clamp-3 block text-[12px] leading-relaxed text-muted-foreground">{source.snippet}</span>}
</>
)}
</span>
)}
</span>
);
}
Registry item: /r/citation.json