Citations
Citations for AI answers in five forms. Source cards with how often each is cited, footnotes with jump and back, pills that preview the quoted passage, a source panel that follows the chosen sentence and a check of which claims a source backs.
One of 4 components in AI answers
Variants
Details
What Citations brings along, read from the registry and the source.
- Category
- AI answers
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/citations.tsx
- Brings along
- utilskiticonrevealmotion
- npm packages
- None
- Languages
- English and German, 25 texts of its own
- Studio
- All 5 variants editable
- In collections
- AI product
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CitationsProps interface from components/ui/citations.tsx, with the comments from the code.
variantoptionalCitationsVariantsource-cardsfootnoteshover-previewside-panelclaim-check
titleoptionalstringquestionoptionalstringThe question that was asked
answeroptionalstring[]Paragraphs of the answer. [1] or [1, 3] cite sources, **bold** works.
sourcesoptionalCitationSource[]claimsoptionalCitationClaim[]claim-check
Built-in texts
Citations writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 25 texts
| Key | English | German |
|---|---|---|
| sources | English Sources | German Quellen |
| answer | English Answer | German Antwort |
| usedIn | English Cited {n} times | German {n}-mal zitiert |
| usedOnce | English Cited once | German Einmal zitiert |
| all | English All | German Alle |
| web | English Web | German Web |
| doc | English Documents | German Dokumente |
| internal | English Internal | German Intern |
| paper | English Studies | German Studien |
| open | English Open source | German Quelle öffnen |
| footnotes | English Notes | German Anmerkungen |
| back | English Back to the text | German Zurück zum Text |
| note | English Note {n} | German Anmerkung {n} |
| preview | English Preview of source {n} | German Vorschau auf Quelle {n} |
| quoted | English Quoted passage | German Zitierte Stelle |
| verify | English Check against the source | German Mit der Quelle abgleichen |
| supported | English Backed by the source | German Von der Quelle gedeckt |
| partial | English Partly backed | German Teilweise gedeckt |
| missing | English No source found | German Keine Quelle gefunden |
| evidence | English In the source | German In der Quelle |
| noEvidence | English None of the sources says this. Treat it as unconfirmed. | German Keine der Quellen sagt das. Behandle es als unbestätigt. |
| summary | English {ok} of {total} claims backed by a source | German {ok} von {total} Aussagen durch eine Quelle gedeckt |
| honest | English Sources can be outdated or wrong. Open them before you rely on an answer. | German Quellen können veraltet oder falsch sein. Öffne sie, bevor du dich auf eine Antwort verlässt. |
| selectHint | English Select a sentence to see its source | German Wähl einen Satz, um seine Quelle zu sehen |
| sentence | English Sentence with source {n} | German Satz mit Quelle {n} |
Accessibility
Read from the source of the component.
- Citations handles the keys Escape, Enter and Space itself.
- Sets aria-controls, aria-expanded, aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the roles button, dialog and group.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 821 lines
'use client';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';
export type CitationsVariant = 'source-cards' | 'footnotes' | 'hover-preview' | 'side-panel' | 'claim-check';
export interface CitationSource {
id: number;
title: string;
/** Domain or place, e.g. "kanzlei-berger.de" or "Contracts folder" */
site: string;
href?: string;
/** e.g. "Mar 2026" */
date?: string;
kind?: 'web' | 'doc' | 'internal' | 'paper';
/** The quoted passage. ==Marked words== are the part the answer relies on. */
excerpt?: string;
}
export interface CitationClaim {
id: string;
claim: string;
/** Source id */
source?: number;
/** The words in the source that back the claim */
quote?: string;
status: 'supported' | 'partial' | 'missing';
note?: string;
}
export interface CitationsProps {
variant?: CitationsVariant;
title?: string;
/** The question that was asked */
question?: string;
/** Paragraphs of the answer. [1] or [1, 3] cite sources, **bold** works. */
answer?: string[];
sources?: CitationSource[];
/** claim-check */
claims?: CitationClaim[];
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
sources: 'Sources',
answer: 'Answer',
usedIn: 'Cited {n} times',
usedOnce: 'Cited once',
all: 'All',
web: 'Web',
doc: 'Documents',
internal: 'Internal',
paper: 'Studies',
open: 'Open source',
footnotes: 'Notes',
back: 'Back to the text',
note: 'Note {n}',
preview: 'Preview of source {n}',
quoted: 'Quoted passage',
verify: 'Check against the source',
supported: 'Backed by the source',
partial: 'Partly backed',
missing: 'No source found',
evidence: 'In the source',
noEvidence: 'None of the sources says this. Treat it as unconfirmed.',
summary: '{ok} of {total} claims backed by a source',
honest: 'Sources can be outdated or wrong. Open them before you rely on an answer.',
selectHint: 'Select a sentence to see its source',
sentence: 'Sentence with source {n}',
},
de: {
sources: 'Quellen',
answer: 'Antwort',
usedIn: '{n}-mal zitiert',
usedOnce: 'Einmal zitiert',
all: 'Alle',
web: 'Web',
doc: 'Dokumente',
internal: 'Intern',
paper: 'Studien',
open: 'Quelle öffnen',
footnotes: 'Anmerkungen',
back: 'Zurück zum Text',
note: 'Anmerkung {n}',
preview: 'Vorschau auf Quelle {n}',
quoted: 'Zitierte Stelle',
verify: 'Mit der Quelle abgleichen',
supported: 'Von der Quelle gedeckt',
partial: 'Teilweise gedeckt',
missing: 'Keine Quelle gefunden',
evidence: 'In der Quelle',
noEvidence: 'Keine der Quellen sagt das. Behandle es als unbestätigt.',
summary: '{ok} von {total} Aussagen durch eine Quelle gedeckt',
honest: 'Quellen können veraltet oder falsch sein. Öffne sie, bevor du dich auf eine Antwort verlässt.',
selectHint: 'Wähl einen Satz, um seine Quelle zu sehen',
sentence: 'Satz mit Quelle {n}',
},
};
type Texts = (typeof TEXTS)['en'];
type P = CitationsProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const CSS =
'@keyframes cit-up{from{opacity:0;transform:translateY(8px)}}@keyframes cit-pop{from{opacity:0;transform:translateY(4px) scale(.97)}}@keyframes cit-flash{from{background-color:color-mix(in oklab,var(--primary) 30%,transparent)}}' +
'@media (prefers-reduced-motion:no-preference){.cit-up{animation:cit-up .45s var(--ease-kit) var(--cit-d,0ms) both}.cit-pop{animation:cit-pop .22s var(--ease-kit) both}.cit-flash{animation:cit-flash 1.4s ease-out both}}';
function Style() {
return (
<style href="kit-citations" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const TONES = ['--primary', '--info', '--success', '--warning', '--destructive'];
const KIND_ICON: Record<NonNullable<CitationSource['kind']>, IconName> = { web: 'globe', doc: 'file', internal: 'folder', paper: 'book-open' };
function SiteTile({ source, size = 28 }: { source: CitationSource; size?: number }) {
const tone = TONES[(source.id - 1) % TONES.length];
return (
<span aria-hidden className="grid shrink-0 place-items-center rounded-lg text-[12px] font-semibold uppercase" style={{ width: size, height: size, background: `color-mix(in oklab, var(${tone}) 16%, var(--card))`, color: `color-mix(in oklab, var(${tone}) 72%, var(--foreground))`, fontSize: Math.round(size * 0.42) }}>
{source.kind && source.kind !== 'web' ? <Icon name={KIND_ICON[source.kind]} size={Math.round(size * 0.5)} /> : source.site.replace(/^www\./, '').charAt(0)}
</span>
);
}
/** Excerpt with ==marked== words */
function Excerpt({ text, className }: { text: string; className?: string }) {
return (
<span className={className}>
{text.split(/(==[^=]+==)/g).map((part, i) =>
part.startsWith('==') ? (
<mark key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--warning)_30%,transparent)] px-0.5 text-foreground">
{part.slice(2, -2)}
</mark>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</span>
);
}
function bold(text: string) {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => (part.startsWith('**') ? <strong key={i} className="font-semibold text-foreground">{part.slice(2, -2)}</strong> : <Fragment key={i}>{part}</Fragment>));
}
/** Splits a paragraph into text and [n, m] markers */
function parts(text: string) {
const out: ({ text: string } | { ids: number[] })[] = [];
let last = 0;
for (const m of text.matchAll(/\s?\[(\d+(?:\s*,\s*\d+)*)\]/g)) {
if (m.index! > last) out.push({ text: text.slice(last, m.index) });
out.push({ ids: m[1].split(',').map((x) => Number(x.trim())) });
last = m.index! + m[0].length;
}
if (last < text.length) out.push({ text: text.slice(last) });
return out;
}
type Glued = { text: string } | { lead: string; ids: number[] };
/** Like parts, but each marker carries the word before it and the punctuation after it */
function glue(text: string): Glued[] {
const ps = parts(text) as ({ text: string } | { ids: number[]; lead?: string })[];
for (let i = 0; i < ps.length; i++) {
const x = ps[i];
if (!('ids' in x)) continue;
const prev = ps[i - 1];
const next = ps[i + 1];
let lead = '';
if (prev && 'text' in prev) {
const m = /(\S+)$/.exec(prev.text);
if (m) {
lead = m[1];
prev.text = prev.text.slice(0, m.index);
}
}
if (next && 'text' in next) {
const m = /^[.,;:!?)]+/.exec(next.text);
if (m) {
lead += m[0];
next.text = next.text.slice(m[0].length);
}
}
x.lead = lead;
}
return ps.map((x) => ('ids' in x ? { lead: x.lead ?? '', ids: x.ids } : x));
}
/** Sentences of a paragraph, each with the ids it cites */
function sentences(text: string) {
return text.split(/(?<=[.!?])\s+(?=[A-ZÄÖÜ„“"(])/).map((s) => ({ text: s.replace(/\s?\[[\d,\s]+\]/g, '').trim(), ids: Array.from(s.matchAll(/\[(\d+(?:\s*,\s*\d+)*)\]/g)).flatMap((m) => m[1].split(',').map((x) => Number(x.trim()))) }));
}
function Stage({ title, question, children, className, wide }: { title?: string; question?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[46rem]')}>
{(title || question) && (
<Reveal className="mb-6 sm:mb-8">
{title && <p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]"><Icon name="quote" size={14} />{title}</p>}
{question && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[28px]">{question}</h2>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function Honest({ t }: { t: Texts }) {
return (
<p className="mt-5 flex items-start gap-2 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="info" size={14} className="mt-px shrink-0" />
{t.honest}
</p>
);
}
const markCls = 'inline-flex h-[1.35em] min-w-[1.35em] items-center justify-center rounded-[5px] px-1 align-[0.12em] text-[11px] font-semibold tabular-nums leading-none transition-colors duration-200';
/* ------------------------------------------------------------------ */
/* source-cards */
/* ------------------------------------------------------------------ */
function SourceCards({ t, title, question, answer = [], sources = [], className }: P) {
const [focus, setFocus] = useState<number | null>(null);
const [kind, setKind] = useState<string>('all');
const [row, fade] = useScrollFade<HTMLDivElement>();
// A number in the text lights up its card and brings it into view, also when a filter hid it
const pick = (id: number) => {
const next = focus === id ? null : id;
setFocus(next);
if (next == null) return;
const k = sources.find((s) => s.id === id)?.kind ?? 'web';
if (kind !== 'all' && kind !== k) setKind('all');
requestAnimationFrame(() => row.current?.querySelector(`[data-card="${id}"]`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'nearest', inline: 'nearest' }));
};
const uses = (id: number) => answer.reduce((n, p) => n + parts(p).filter((x) => 'ids' in x && x.ids.includes(id)).length, 0);
const kinds = Array.from(new Set(sources.map((s) => s.kind ?? 'web')));
const shown = sources.filter((s) => kind === 'all' || (s.kind ?? 'web') === kind);
return (
<Stage title={title} question={question} className={className} wide>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] font-medium text-foreground">
{t.sources} <span className="tabular-nums text-muted-foreground">{sources.length}</span>
</p>
{kinds.length > 1 && (
<div role="group" aria-label={t.sources} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
{['all', ...kinds].map((k) => (
<button key={k} type="button" aria-pressed={kind === k} onClick={() => setKind(k)} className={cn('h-9 rounded-md px-2.5 text-[12.5px] font-medium transition-colors max-sm:h-11 max-sm:min-w-11', kind === k ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{t[k as 'all' | 'web']}
</button>
))}
</div>
)}
</div>
<div ref={row} tabIndex={0} aria-label={t.sources} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 pb-1 sm:mx-0 sm:px-0 lg:overflow-visible', fade, 'lg:[mask-image:none]')}>
<Stagger as="ul" step={70} className="flex w-max gap-3 lg:grid lg:w-auto lg:grid-cols-4">
{shown.map((s) => {
const n = uses(s.id);
const on = focus === s.id;
return (
<li key={s.id} data-card={s.id} className="w-[15.5rem] lg:w-auto">
<button
type="button"
aria-pressed={on}
onClick={() => setFocus(on ? null : s.id)}
className={cn(
'group flex h-full w-full flex-col rounded-xl border bg-card p-3.5 text-left shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0',
on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))]' : 'border-border hover:border-border-strong',
)}
>
<span className="flex items-center gap-2">
<SiteTile source={s} size={24} />
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">{s.site}</span>
<span className={cn(markCls, on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{s.id}</span>
</span>
<span className="mt-2.5 line-clamp-2 text-[13.5px] font-medium leading-snug text-foreground">{s.title}</span>
<span className="mt-auto flex items-center justify-between gap-2 pt-3 text-[11.5px] text-subtle-foreground">
<span>{s.date}</span>
<span className="tabular-nums">{n === 1 ? t.usedOnce : fill(t.usedIn, { n })}</span>
</span>
</button>
</li>
);
})}
</Stagger>
</div>
<Reveal delay={120}>
<div className="mt-6 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<p className="mb-3 flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
<Icon name="sparkle" size={14} className="text-primary" />
{t.answer}
</p>
<div className="space-y-3.5 text-pretty text-[15px] leading-relaxed text-muted-foreground">
{answer.map((p, i) => (
<p key={i}>
{glue(p).map((x, j) =>
'text' in x ? (
<Fragment key={j}>{bold(x.text)}</Fragment>
) : (
<span key={j} className="whitespace-nowrap">
{bold(x.lead)}
<span className="ml-1 inline-flex gap-0.5">
{x.ids.map((id) => (
<button key={id} type="button" aria-label={fill(t.note, { n: id })} aria-pressed={focus === id} onClick={() => pick(id)} className={cn(markCls, "relative after:absolute after:-inset-x-0.5 after:-inset-y-4 after:content-['']", focus === id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_24%,var(--card))]')}>
{id}
</button>
))}
</span>
</span>
),
)}
</p>
))}
</div>
</div>
</Reveal>
<Honest t={t} />
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* footnotes */
/* ------------------------------------------------------------------ */
function Footnotes({ t, title, question, answer = [], sources = [], className }: P) {
const uid = useId().replace(/:/g, '');
const [flash, setFlash] = useState<string | null>(null);
const go = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'center' });
el.focus({ preventScroll: true });
setFlash(id);
};
useEffect(() => {
if (!flash) return;
const id = window.setTimeout(() => setFlash(null), 1500);
return () => window.clearTimeout(id);
}, [flash]);
const used = sources.filter((s) => answer.some((p) => parts(p).some((x) => 'ids' in x && x.ids.includes(s.id))));
return (
<Stage title={title} question={question} className={className}>
<Reveal delay={80}>
<article className="rounded-2xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-8 sm:py-8">
<div className="space-y-4 text-pretty text-[15.5px] leading-[1.75] text-muted-foreground">
{answer.map((p, i) => (
<p key={i}>
{glue(p).map((x, j) =>
'text' in x ? (
<Fragment key={j}>{bold(x.text)}</Fragment>
) : (
<span key={j} className="whitespace-nowrap">
{bold(x.lead)}
{x.ids.map((id, k) => (
<sup key={`${j}-${id}`} className={k ? undefined : "ml-0.5"}>
{k > 0 && <span className="text-[11.5px] text-muted-foreground">,</span>}
<a
id={`${uid}-ref-${i}-${j}-${k}`}
href={`#${uid}-fn-${id}`}
aria-label={fill(t.note, { n: id })}
onClick={(e) => (e.preventDefault(), go(`${uid}-fn-${id}`))}
className={cn('rounded-[4px] px-0.5 text-[11.5px] font-semibold text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] underline decoration-[color-mix(in_oklab,var(--primary)_40%,transparent)] underline-offset-2 hover:decoration-primary', flash === `${uid}-ref-${i}-${j}-${k}` && 'cit-flash')}
>
{id}
</a>
</sup>
))}
</span>
),
)}
</p>
))}
</div>
<hr className="my-7 w-16 border-border-strong" />
<h3 className="mb-3 text-[13px] font-medium text-foreground">{t.footnotes}</h3>
<Stagger as="ol" step={60} className="space-y-1">
{used.map((s) => {
const firstRef = (() => {
for (let i = 0; i < answer.length; i++) {
const ps = parts(answer[i]);
for (let j = 0; j < ps.length; j++) {
const x = ps[j];
if ('ids' in x && x.ids.includes(s.id)) return `${uid}-ref-${i}-${j}-${x.ids.indexOf(s.id)}`;
}
}
return '';
})();
const id = `${uid}-fn-${s.id}`;
return (
<li key={s.id} id={id} tabIndex={-1} className={cn('grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2 rounded-lg px-2 py-2 text-[13.5px] leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-primary', flash === id && 'cit-flash')}>
<span className="pt-px text-right font-semibold tabular-nums text-muted-foreground">{s.id}.</span>
<span className="min-w-0">
<span className="text-foreground">{s.title}</span>
<span className="text-muted-foreground">
{' '}
· {s.site}
{s.date && `, ${s.date}`}
</span>
{s.excerpt && <Excerpt text={s.excerpt} className="mt-1.5 block border-l-2 border-border-strong pl-2.5 text-[13px] italic text-muted-foreground" />}
<span className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{s.href && (
<a href={s.href} className="inline-flex min-h-8 items-center gap-1 text-[12.5px] font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
{t.open}
<Icon name="arrow-up-right" size={13} />
</a>
)}
<a href={`#${firstRef}`} onClick={(e) => (e.preventDefault(), go(firstRef))} className="inline-flex min-h-8 items-center gap-1 text-[12.5px] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline max-sm:min-h-11">
<Icon name="corner-down-left" size={13} className="rotate-90" />
{t.back}
</a>
</span>
</span>
</li>
);
})}
</Stagger>
</article>
</Reveal>
<Honest t={t} />
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* hover-preview */
/* ------------------------------------------------------------------ */
function Pill({ s, t, defaultOpen }: { s: CitationSource; t: Texts; defaultOpen?: boolean }) {
const [open, setOpen] = useState(!!defaultOpen);
const [shift, setShift] = useState(0);
const [up, setUp] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const id = useId();
const hide = useRef<number | undefined>(undefined);
const show = () => {
window.clearTimeout(hide.current);
const r = ref.current?.getBoundingClientRect();
if (r) {
const w = Math.min(304, window.innerWidth - 32);
setShift(Math.round(Math.max(16 - r.left, Math.min(0, window.innerWidth - 16 - w - r.left))));
// Near the end of the block the card opens upward, the block clips what hangs out below
const box = ref.current?.closest('section')?.getBoundingClientRect();
setUp(!!box && r.bottom + 280 > box.bottom && r.top - 280 > box.top);
}
setOpen(true);
};
const later = () => {
hide.current = window.setTimeout(() => setOpen(false), 160);
};
useEffect(() => {
if (defaultOpen) show();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!open) return;
const esc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
const away = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
document.addEventListener('keydown', esc);
document.addEventListener('pointerdown', away);
return () => {
document.removeEventListener('keydown', esc);
document.removeEventListener('pointerdown', away);
};
}, [open]);
return (
<span
ref={ref}
className="relative inline-block align-baseline"
onPointerEnter={(e) => e.pointerType === 'mouse' && show()}
onPointerLeave={(e) => e.pointerType === 'mouse' && later()}
// Tabbing from the pill to the link inside the card keeps it open
onFocus={() => window.clearTimeout(hide.current)}
onBlur={(e) => !ref.current?.contains(e.relatedTarget as Node) && later()}
>
<button
type="button"
aria-expanded={open}
aria-controls={id}
aria-label={fill(t.preview, { n: s.id })}
onClick={() => (open ? setOpen(false) : show())}
onFocus={show}
className={cn("relative mx-0.5 inline-flex h-6 translate-y-[-1px] items-center gap-1 rounded-full border pl-1 pr-2 align-middle text-[11.5px] font-medium transition-colors duration-200 after:absolute after:-inset-y-2.5 after:inset-x-0 after:content-[''] max-sm:h-7", open ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border bg-card text-muted-foreground hover:text-foreground')}
>
<SiteTile source={s} size={16} />
{s.site.replace(/^www\./, '').split('.')[0]}
</button>
{open && (
<span
id={id}
role="dialog"
aria-label={fill(t.preview, { n: s.id })}
onPointerEnter={() => window.clearTimeout(hide.current)}
onPointerLeave={later}
style={{ left: shift }}
className={cn('cit-pop absolute z-30 block', up ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]', 'w-[min(19rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-3.5 text-left not-italic shadow-[var(--highlight),var(--shadow-lg)]')}
>
<span className="flex items-center gap-2">
<SiteTile source={s} size={22} />
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
{s.site}
{s.date && ` · ${s.date}`}
</span>
</span>
<span className="mt-2 block text-[13.5px] font-semibold leading-snug text-foreground">{s.title}</span>
{s.excerpt && (
<span className="mt-2 block border-l-2 border-[color-mix(in_oklab,var(--warning)_60%,var(--border))] pl-2.5 text-[12.5px] leading-relaxed text-muted-foreground">
<span className="sr-only">{t.quoted}: </span>
<Excerpt text={s.excerpt} />
</span>
)}
{s.href && (
<a href={s.href} className="mt-2.5 inline-flex min-h-11 items-center gap-1 focus-visible:-outline-offset-2 text-[12.5px] font-medium text-foreground underline-offset-4 hover:underline sm:min-h-8">
{t.open}
<Icon name="arrow-up-right" size={13} />
</a>
)}
</span>
)}
</span>
);
}
function HoverPreview({ t, title, question, answer = [], sources = [], className }: P) {
const byId = (id: number) => sources.find((s) => s.id === id);
let first = true;
return (
<Stage title={title} question={question} className={className}>
<Reveal delay={80}>
<div className="rounded-2xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-8 sm:py-8">
<div className="space-y-4 text-pretty text-[15.5px] leading-[1.85] text-muted-foreground">
{answer.map((p, i) => (
<p key={i}>
{glue(p).map((x, j) =>
'text' in x ? (
<Fragment key={j}>{bold(x.text)}</Fragment>
) : (
<Fragment key={j}>
{bold(x.lead)}
{x.ids.map((id) => {
const s = byId(id);
if (!s) return null;
// The first preview starts open, so the page shows what a hover does
const open = first;
first = false;
return <Pill key={`${j}-${id}`} s={s} t={t} defaultOpen={open && i === 0} />;
})}
</Fragment>
),
)}
</p>
))}
</div>
</div>
</Reveal>
<Honest t={t} />
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* side-panel */
/* ------------------------------------------------------------------ */
function SidePanel({ t, title, question, answer = [], sources = [], className }: P) {
const all = answer.map((p) => sentences(p));
const firstCited = all.flat().find((s) => s.ids.length)?.ids[0] ?? sources[0]?.id;
const firstSel = (() => {
for (let p = 0; p < all.length; p++) for (let i = 0; i < all[p].length; i++) if (all[p][i].ids.length) return { p, s: i };
return null;
})();
const [sel, setSel] = useState<{ p: number; s: number } | null>(firstSel);
const [src, setSrc] = useState<number | undefined>(firstCited);
const panel = useRef<HTMLDivElement>(null);
const chosen = useRef(false);
useEffect(() => {
if (src == null) return;
const el = panel.current?.querySelector<HTMLElement>(`[data-src="${src}"]`);
if (!el || !panel.current) return;
const behavior = prefersReducedMotion() ? 'auto' : 'smooth';
const top = el.offsetTop - panel.current.offsetTop - 12;
if (window.matchMedia('(min-width: 1024px)').matches) panel.current.scrollTo({ top, behavior });
// Phones stack the panel below, a chosen sentence brings its source into view
else if (chosen.current) el.scrollIntoView({ behavior, block: 'nearest' });
}, [src]);
const choose = (p: number, s: number, id: number) => {
chosen.current = true;
setSel({ p, s });
setSrc(id);
};
return (
<Stage title={title} question={question} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-6">
<Reveal delay={80}>
<div className="rounded-2xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-7 sm:py-7">
<p className="mb-4 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name="sparkle" size={14} className="text-primary" />
{t.selectHint}
</p>
<div className="space-y-4 text-pretty text-[15.5px] leading-[1.8] text-muted-foreground">
{all.map((ss, pi) => (
<p key={pi}>
{ss.map((s, si) =>
s.ids.length ? (
<Fragment key={si}>
{/* A span keeps the sentence in the flow of the text, a button would wrap as a block */}
<span
role="button"
tabIndex={0}
aria-pressed={sel?.p === pi && sel.s === si}
onClick={() => choose(pi, si, s.ids[0])}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
choose(pi, si, s.ids[0]);
}}
className={cn(
'cursor-pointer rounded-[4px] [box-decoration-break:clone] decoration-[color-mix(in_oklab,var(--primary)_45%,transparent)] decoration-dotted decoration-[1.5px] underline-offset-[5px] transition-colors duration-200 hover:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] hover:text-foreground',
sel?.p === pi && sel.s === si ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground' : 'underline',
)}
>
{bold(s.text)}
<span className="sr-only"> ({fill(t.sentence, { n: s.ids.join(', ') })})</span>
</span>{' '}
</Fragment>
) : (
<Fragment key={si}>{bold(s.text)} </Fragment>
),
)}
</p>
))}
</div>
</div>
</Reveal>
<Reveal delay={180} className="lg:sticky lg:top-6">
<aside aria-label={t.sources} className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground">
{t.sources} <span className="tabular-nums text-muted-foreground">{sources.length}</span>
</p>
<div ref={panel} className="kit-scrollbar relative space-y-2 p-2.5 lg:max-h-[30rem] lg:overflow-y-auto">
{sources.map((s) => {
const on = s.id === src;
return (
<button
key={s.id}
data-src={s.id}
type="button"
aria-pressed={on}
onClick={() => setSrc(s.id)}
className={cn('block w-full rounded-xl border p-3 text-left transition-[border-color,background-color] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-transparent hover:bg-accent/60')}
>
<span className="flex items-center gap-2">
<SiteTile source={s} size={22} />
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">{s.site}</span>
<span className={cn(markCls, on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{s.id}</span>
</span>
<span className="mt-1.5 block text-[13.5px] font-medium leading-snug text-foreground">{s.title}</span>
{s.excerpt && (
<span className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)]', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<span className="overflow-hidden">
<span className="mt-2 block border-l-2 border-[color-mix(in_oklab,var(--warning)_60%,var(--border))] pl-2.5 text-[12.5px] leading-relaxed text-muted-foreground">
<Excerpt text={s.excerpt} />
</span>
</span>
</span>
)}
</button>
);
})}
</div>
</aside>
</Reveal>
</div>
<Honest t={t} />
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* claim-check */
/* ------------------------------------------------------------------ */
const STATUS = {
supported: { icon: 'check' as IconName, cls: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground', line: 'border-[color-mix(in_oklab,var(--success)_45%,var(--border))]' },
partial: { icon: 'minus' as IconName, cls: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground', line: 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))]' },
missing: { icon: 'help-circle' as IconName, cls: 'bg-[color-mix(in_oklab,var(--destructive)_13%,var(--card))] text-destructive', line: 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' },
};
function ClaimCheck({ t, title, question, claims = [], sources = [], className }: P) {
const [open, setOpen] = useState<string | null>(claims.find((c) => c.status !== 'supported')?.id ?? claims[0]?.id ?? null);
const ok = claims.filter((c) => c.status === 'supported').length;
return (
<Stage title={title} question={question} className={className}>
<Reveal delay={60}>
<div className="mb-4 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
<div className="flex items-baseline justify-between gap-3">
<p className="text-[14px] font-medium text-foreground">{fill(t.summary, { ok, total: claims.length })}</p>
<p className="text-[13px] tabular-nums text-muted-foreground">
{ok}/{claims.length}
</p>
</div>
<div aria-hidden className="mt-3 flex h-2 gap-1">
{claims.map((c, i) => (
<span key={c.id} className={cn('cit-up h-full flex-1 rounded-full', c.status === 'supported' ? 'bg-success' : c.status === 'partial' ? 'bg-warning' : 'bg-destructive')} style={{ '--cit-d': `${200 + i * 90}ms` } as React.CSSProperties} />
))}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
{(['supported', 'partial', 'missing'] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className={cn('size-2 rounded-full', k === 'supported' ? 'bg-success' : k === 'partial' ? 'bg-warning' : 'bg-destructive')} />
{t[k]}
</span>
))}
</div>
</div>
</Reveal>
<Stagger as="ul" step={70} className="space-y-2.5">
{claims.map((c) => {
const s = sources.find((x) => x.id === c.source);
const st = STATUS[c.status];
const on = open === c.id;
return (
<li key={c.id} className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<button type="button" aria-expanded={on} onClick={() => setOpen(on ? null : c.id)} className="flex w-full items-start gap-3 p-4 text-left transition-colors focus-visible:-outline-offset-2 hover:bg-accent/40 sm:px-5">
<span className={cn('mt-0.5 grid size-6 shrink-0 place-items-center rounded-full', st.cls)}>
<Icon name={st.icon} size={13} strokeWidth={2.2} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-pretty text-[14.5px] leading-snug text-foreground">{c.claim}</span>
<span className={cn('mt-1 block text-[12px] font-medium', c.status === 'supported' ? 'text-success-foreground' : c.status === 'partial' ? 'text-warning-foreground' : 'text-destructive')}>{t[c.status]}</span>
</span>
<Icon name="chevron-down" size={16} className={cn('mt-1 shrink-0 text-muted-foreground transition-transform duration-300', on && 'rotate-180')} />
</button>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden" inert={!on || undefined}>
<div className="px-4 pb-4 sm:px-5 sm:pl-14">
{s && c.quote ? (
<div className={cn('rounded-xl border-l-2 bg-background/60 py-2.5 pl-3.5 pr-3', st.line)}>
<p className="flex items-center gap-2 text-[12px] text-muted-foreground">
<SiteTile source={s} size={18} />
<span className="truncate">
{t.evidence} · {s.site}
</span>
</p>
<Excerpt text={c.quote} className="mt-1.5 block text-[13.5px] italic leading-relaxed text-muted-foreground" />
</div>
) : (
<p className="rounded-xl border border-dashed border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))] px-3.5 py-2.5 text-[13px] text-muted-foreground">{t.noEvidence}</p>
)}
{c.note && <p className="mt-2 text-[12.5px] leading-relaxed text-muted-foreground">{c.note}</p>}
{s?.href && (
<a href={s.href} className="mt-2 inline-flex min-h-9 items-center gap-1 text-[12.5px] font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
{t.verify}
<Icon name="arrow-up-right" size={13} />
</a>
)}
</div>
</div>
</div>
</li>
);
})}
</Stagger>
<Honest t={t} />
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* Citations for AI answers: source cards with how often each is cited,
* numbered footnotes with jump and back, citation pills with a preview of the
* quoted passage, a source panel that follows the selected sentence and a
* claim check that says which statements a source really backs.
*/
export function Citations({ variant = 'source-cards', labels, ...props }: CitationsProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'footnotes':
return <Footnotes {...p} />;
case 'hover-preview':
return <HoverPreview {...p} />;
case 'side-panel':
return <SidePanel {...p} />;
case 'claim-check':
return <ClaimCheck {...p} />;
default:
return <SourceCards {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Frequent questions
How do I install Citations?
With aiw add @ai-website/citations. The command puts components/ui/citations.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.
Which variants does Citations have?
5 variants, Source cards, Footnotes, Hover preview, Source panel and Claim check. You pick one with the variant prop, for example variant="footnotes".
Can I translate the texts of Citations?
Yes. Citations carries 25 texts of its own in English and German, such as “Sources”, “Answer” and “Cited once”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Citations?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Citations with aiw_components and install it with aiw_add @ai-website/citations. Name the variant in your prompt, for example variant="source-cards", and the assistant writes your content straight into the props.
Can I adjust Citations without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Citations code to copy.