Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Quellenkarten

Quellen als Karten über der Antwort mit Filter nach Art und Anzahl der Zitate. Eine Zahl im Text lässt ihre Karte aufleuchten und umgekehrt.

Im Studio öffnen

Fußnoten

Nummern im Text springen zu ihrer Anmerkung, die Anmerkung zeigt die zitierte Stelle und führt zurück. Beide leuchten beim Ankommen kurz auf.

Im Studio öffnen

Vorschau beim Zeigen

Belege als kleine Marken im Satz. Zeigen, Fokus oder Tippen öffnet eine Karte mit Quelle und markierter Stelle, Escape schließt sie.

Im Studio öffnen

Quellenleiste

Belegte Sätze sind unterstrichen. Ein gewählter Satz öffnet seine Quelle in der Leiste neben der Antwort mit der Stelle, auf die er sich stützt. Telefone stapeln die Leiste darunter.

Im Studio öffnen

Aussagen prüfen

Jede Aussage einer Antwort mit ihrem Beleg, markiert als gedeckt, teilweise gedeckt oder ohne Quelle, als Übersicht und als Zeilen, die die zitierten Worte öffnen.

Im Studio öffnen

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

Props

Die Schnittstelle CitationsProps aus components/ui/citations.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CitationsVariant

    source-cardsfootnoteshover-previewside-panelclaim-check

  • titleoptional
    string
  • questionoptional
    string

    The question that was asked

  • answeroptional
    string[]

    Paragraphs of the answer. [1] or [1, 3] cite sources, **bold** works.

  • sourcesoptional
    CitationSource[]
  • claimsoptional
    CitationClaim[]

    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
sourcesEnglisch SourcesDeutsch Quellen
answerEnglisch AnswerDeutsch Antwort
usedInEnglisch Cited {n} timesDeutsch {n}-mal zitiert
usedOnceEnglisch Cited onceDeutsch Einmal zitiert
allEnglisch AllDeutsch Alle
webEnglisch WebDeutsch Web
docEnglisch DocumentsDeutsch Dokumente
internalEnglisch InternalDeutsch Intern
paperEnglisch StudiesDeutsch Studien
openEnglisch Open sourceDeutsch Quelle öffnen
footnotesEnglisch NotesDeutsch Anmerkungen
backEnglisch Back to the textDeutsch Zurück zum Text
noteEnglisch Note {n}Deutsch Anmerkung {n}
previewEnglisch Preview of source {n}Deutsch Vorschau auf Quelle {n}
quotedEnglisch Quoted passageDeutsch Zitierte Stelle
verifyEnglisch Check against the sourceDeutsch Mit der Quelle abgleichen
supportedEnglisch Backed by the sourceDeutsch Von der Quelle gedeckt
partialEnglisch Partly backedDeutsch Teilweise gedeckt
missingEnglisch No source foundDeutsch Keine Quelle gefunden
evidenceEnglisch In the sourceDeutsch In der Quelle
noEvidenceEnglisch None of the sources says this. Treat it as unconfirmed.Deutsch Keine der Quellen sagt das. Behandle es als unbestätigt.
summaryEnglisch {ok} of {total} claims backed by a sourceDeutsch {ok} von {total} Aussagen durch eine Quelle gedeckt
honestEnglisch 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.
selectHintEnglisch Select a sentence to see its sourceDeutsch Wähl einen Satz, um seine Quelle zu sehen
sentenceEnglisch 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
components/ui/citations.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert