Belege und Quellen
Belege für KI-Antworten in fünf Formen. Quellenkarten mit Anzahl der Zitate, Fußnoten mit Sprung und Rückweg, Marken mit Vorschau der zitierten Stelle, eine Quellenleiste, die dem gewählten Satz folgt, und eine Prüfung, welche Aussagen eine Quelle wirklich deckt.
Eine von 4 Components in KI-Antworten
Variants
Details
Was Belege und Quellen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Antworten
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/citations.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 25 eigene Texte
- In Sammlungen
- KI-Produkt
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CitationsProps aus components/ui/citations.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Belege und Quellen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 25 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| sources | Englisch Sources | Deutsch Quellen |
| answer | Englisch Answer | Deutsch Antwort |
| usedIn | Englisch Cited {n} times | Deutsch {n}-mal zitiert |
| usedOnce | Englisch Cited once | Deutsch Einmal zitiert |
| all | Englisch All | Deutsch Alle |
| web | Englisch Web | Deutsch Web |
| doc | Englisch Documents | Deutsch Dokumente |
| internal | Englisch Internal | Deutsch Intern |
| paper | Englisch Studies | Deutsch Studien |
| open | Englisch Open source | Deutsch Quelle öffnen |
| footnotes | Englisch Notes | Deutsch Anmerkungen |
| back | Englisch Back to the text | Deutsch Zurück zum Text |
| note | Englisch Note {n} | Deutsch Anmerkung {n} |
| preview | Englisch Preview of source {n} | Deutsch Vorschau auf Quelle {n} |
| quoted | Englisch Quoted passage | Deutsch Zitierte Stelle |
| verify | Englisch Check against the source | Deutsch Mit der Quelle abgleichen |
| supported | Englisch Backed by the source | Deutsch Von der Quelle gedeckt |
| partial | Englisch Partly backed | Deutsch Teilweise gedeckt |
| missing | Englisch No source found | Deutsch Keine Quelle gefunden |
| evidence | Englisch In the source | Deutsch In der Quelle |
| noEvidence | Englisch None of the sources says this. Treat it as unconfirmed. | Deutsch Keine der Quellen sagt das. Behandle es als unbestätigt. |
| summary | Englisch {ok} of {total} claims backed by a source | Deutsch {ok} von {total} Aussagen durch eine Quelle gedeckt |
| honest | Englisch Sources can be outdated or wrong. Open them before you rely on an answer. | Deutsch Quellen können veraltet oder falsch sein. Öffne sie, bevor du dich auf eine Antwort verlässt. |
| selectHint | Englisch Select a sentence to see its source | Deutsch Wähl einen Satz, um seine Quelle zu sehen |
| sentence | Englisch Sentence with source {n} | Deutsch Satz mit Quelle {n} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Belege und Quellen reagiert selbst auf die Tasten Escape, Enter und Leertaste.
- Setzt aria-controls, aria-expanded, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen button, dialog und group.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 821 Zeilen
'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}
</>
);
}Häufige Fragen
Wie installiere ich Belege und Quellen?
Mit aiw add @ai-website/citations. Der Befehl legt die Datei components/ui/citations.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Belege und Quellen?
5 Varianten, Quellenkarten, Fußnoten, Vorschau beim Zeigen, Quellenleiste und Aussagen prüfen. Du wählst sie mit der Prop variant, zum Beispiel variant="footnotes".
Kann ich die Texte von Belege und Quellen übersetzen?
Ja. Belege und Quellen bringt 25 eigene Texte auf Deutsch und Englisch mit, etwa „Quellen“, „Antwort“ und „Einmal zitiert“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Belege und Quellen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Belege und Quellen mit aiw_components und installieren sie mit aiw_add @ai-website/citations. Nenn im Prompt die Variante, etwa variant="source-cards", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Belege und Quellen ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Citations-Code zum Kopieren.