Skip to content
UI Kit
Accent color

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

5 variants

Variants

Source cards

Sources as cards above the answer with type filter and citation count. Pressing a number in the text lights up its card and the other way round.

Open in Studio

Footnotes

Numbered references in the text jump to their note, the note shows the quoted passage and leads back to its place. Both flash briefly on arrival.

Open in Studio

Hover preview

Citations as small pills inside the sentence. Hover, focus or tap opens a card with the source and the marked passage, Escape closes it.

Open in Studio

Source panel

Cited sentences are underlined. Choosing one opens its source in the panel beside the answer with the passage it relies on. Phones stack the panel below.

Open in Studio

Claim check

Each claim of an answer with its evidence, marked as backed, partly backed or without a source, as a summary bar and as rows that open the quoted words.

Open in Studio

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
In collections
AI product

Props

The CitationsProps interface from components/ui/citations.tsx, with the comments from the code.

  • 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

Built-in texts

Citations writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 25 texts
sourcesEnglish SourcesGerman Quellen
answerEnglish AnswerGerman Antwort
usedInEnglish Cited {n} timesGerman {n}-mal zitiert
usedOnceEnglish Cited onceGerman Einmal zitiert
allEnglish AllGerman Alle
webEnglish WebGerman Web
docEnglish DocumentsGerman Dokumente
internalEnglish InternalGerman Intern
paperEnglish StudiesGerman Studien
openEnglish Open sourceGerman Quelle öffnen
footnotesEnglish NotesGerman Anmerkungen
backEnglish Back to the textGerman Zurück zum Text
noteEnglish Note {n}German Anmerkung {n}
previewEnglish Preview of source {n}German Vorschau auf Quelle {n}
quotedEnglish Quoted passageGerman Zitierte Stelle
verifyEnglish Check against the sourceGerman Mit der Quelle abgleichen
supportedEnglish Backed by the sourceGerman Von der Quelle gedeckt
partialEnglish Partly backedGerman Teilweise gedeckt
missingEnglish No source foundGerman Keine Quelle gefunden
evidenceEnglish In the sourceGerman In der Quelle
noEvidenceEnglish None of the sources says this. Treat it as unconfirmed.German Keine der Quellen sagt das. Behandle es als unbestätigt.
summaryEnglish {ok} of {total} claims backed by a sourceGerman {ok} von {total} Aussagen durch eine Quelle gedeckt
honestEnglish 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.
selectHintEnglish Select a sentence to see its sourceGerman Wähl einen Satz, um seine Quelle zu sehen
sentenceEnglish 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
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}
    </>
  );
}

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.

Often searched

Jump to

Copied to the clipboard