Skip to content
AI Interfaces block

Answer Source Citations

The published footnotes under an answer — ordered list, real cite and time elements, the claim each source supports, and a staleness warning written in words rather than shown as an amber dot.

231 lineslucide-reactAdded 13 Aug 2026Updated 2 Sept 2026
  • citations
  • sources
  • footnotes
  • provenance
  • trust

What's included

  • components/source-citation-list.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add source-citation-list

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Sources(4)

  1. Database table: Q3 revenue cohortswarehouse.arr_monthly

    Supports: The 6.1% churn figure for accounts under 20 seats

    2,463 accounts · lost ARR $1.24M · seat_band < 20

    Read

  2. Database table: Activation funnel, Jul–Sepwarehouse.events

    Supports: The claim that a second integration predicts retention

    Accounts with ≥2 connected sources churned at 1.6%; with 1 source, 6.4%.

    Read

  3. Document: Pricing handbookdocs/pricing-handbook.md

    Supports: That the volume break applies per order rather than per quarter

    The break is applied per order, not per quarter, so a single large order beats two smaller ones of the same total volume.

    Read

    This document has changed twice since it was indexed.

  4. Web page: Supplier lead times (opens in a new tab)supplier.example.com

    Supports: The 21-day reorder window

    Read

Every figure above traces to one of these. Nothing was inferred without a source.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/source-citation-list.tsx
/**
 * <SourceCitationList> — the footnote apparatus under a generated answer:
 * numbered sources, what each one contributed, and when it was read.
 *
 * Distinct from `<ContextChunkCards>` on purpose. Chunks are the *debugging*
 * view — everything retrieved, scored, including what was ignored. This is
 * the *published* view: only what the answer actually leaned on, in the
 * order it was cited, written for a reader who wants to check a claim.
 *
 * What it gets right:
 *
 *  - It is an `<ol>`. Citations are numbered and the numbers are referenced
 *    from the prose, so the list must genuinely be ordered — a `<ul>` with
 *    hand-drawn numerals breaks the moment one is removed.
 *  - Each entry is a `<cite>` inside a link, which is the element for the
 *    title of a referenced work, and the `<time>` carries a real `dateTime`
 *    so "2 hours ago" is machine-readable rather than a frozen string.
 *  - A staleness warning is text, not a colour. "Read 4 months ago" on a
 *    live page is the single most useful thing this list can tell someone,
 *    and an amber dot does not say it.
 *  - External links announce that they leave the page — `sr-only` text, not
 *    a `title`, and not an icon alone.
 *  - Confidence in a source is expressed as the *claim it supports*, not a
 *    percentage. "Supports: the 6.1% figure" is checkable; "0.82" is not.
 */

import * as React from 'react'
import { AlertTriangle, Database, ExternalLink, FileText, Globe, Quote } from 'lucide-react'

export type CitationKind = 'doc' | 'table' | 'web'

export interface Citation {
  id: number
  title: string
  origin: string
  kind: CitationKind
  /** The claim in the answer this source backs. */
  supports: string
  /** Verbatim excerpt, shown as a pull quote. */
  excerpt?: string
  /** ISO timestamp of when it was read. */
  readAt?: string
  readLabel?: string
  /** Set when the source may have changed since it was read. */
  stale?: string
  href?: string
}

export interface SourceCitationListProps {
  heading?: string
  citations?: Citation[]
  footnote?: string
  className?: string
}

const KIND_ICON = {
  doc: FileText,
  table: Database,
  web: Globe,
} as const

const KIND_LABEL = {
  doc: 'Document',
  table: 'Database table',
  web: 'Web page',
} as const

const DEFAULT_CITATIONS: Citation[] = [
  {
    id: 1,
    title: 'Q3 revenue cohorts',
    origin: 'warehouse.arr_monthly',
    kind: 'table',
    supports: 'The 6.1% churn figure for accounts under 20 seats',
    excerpt: '2,463 accounts · lost ARR $1.24M · seat_band < 20',
    readAt: '2026-08-11T09:14:00Z',
    readLabel: '2 hours ago',
  },
  {
    id: 2,
    title: 'Activation funnel, Jul–Sep',
    origin: 'warehouse.events',
    kind: 'table',
    supports: 'The claim that a second integration predicts retention',
    excerpt: 'Accounts with ≥2 connected sources churned at 1.6%; with 1 source, 6.4%.',
    readAt: '2026-08-11T09:15:00Z',
    readLabel: '2 hours ago',
  },
  {
    id: 3,
    title: 'Pricing handbook',
    origin: 'docs/pricing-handbook.md',
    kind: 'doc',
    supports: 'That the volume break applies per order rather than per quarter',
    excerpt:
      'The break is applied per order, not per quarter, so a single large order beats two smaller ones of the same total volume.',
    readAt: '2026-04-02T11:00:00Z',
    readLabel: '4 months ago',
    stale: 'This document has changed twice since it was indexed.',
  },
  {
    id: 4,
    title: 'Supplier lead times',
    origin: 'supplier.example.com',
    kind: 'web',
    supports: 'The 21-day reorder window',
    readAt: '2026-08-11T09:16:00Z',
    readLabel: '2 hours ago',
    href: '#',
  },
]

/*
  Per-instance id, hashed from props that differ between instances.

  A literal-rooted id is a latent duplicate the moment this block renders
  twice on one document. `aria-labelledby` resolves to whichever element
  with that id comes first, so the second copy is announced with the
  first copy's label. Server component, so no `useId` -- hashing props
  gives each instance its own namespace and stays stable across server and
  client renders in a way a counter would not.
*/
function instanceId(...parts: (string | undefined)[]): string {
  const text = parts.filter(Boolean).join('|')
  let hash = 0
  for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0
  return (hash >>> 0).toString(36).slice(0, 6)
}

export function SourceCitationList({
  heading = 'Sources',
  citations = DEFAULT_CITATIONS,
  footnote = 'Every figure above traces to one of these. Nothing was inferred without a source.',
  className = '',
}: SourceCitationListProps) {
  const uid = instanceId(heading, footnote)

  return (
    <section className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
      <h3 className="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">
        <Quote aria-hidden className="h-3.5 w-3.5" />
        {heading}
        <span className="font-mono normal-case tracking-normal">({citations.length})</span>
      </h3>

      <ol className="space-y-2">
        {citations.map((citation) => {
          const Icon = KIND_ICON[citation.kind]
          const external = Boolean(citation.href)

          return (
            <li
              key={citation.id}
              id={`${uid}-source-${citation.id}`}
              className="rounded-2xl border border-border/60 bg-card p-4"
            >
              <div className="flex gap-3">
                <span
                  aria-hidden
                  className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-muted font-mono text-[11px] font-bold"
                >
                  {citation.id}
                </span>

                <div className="min-w-0 flex-1">
                  <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
                    <Icon aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

                    {external ? (
                      <a
                        href={citation.href}
                        className="rounded text-sm font-semibold underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      >
                        <span className="sr-only">{KIND_LABEL[citation.kind]}: </span>
                        <cite className="not-italic">{citation.title}</cite>
                        <ExternalLink aria-hidden className="ms-1 inline h-3 w-3 align-baseline" />
                        <span className="sr-only"> (opens in a new tab)</span>
                      </a>
                    ) : (
                      <span className="text-sm font-semibold">
                        <span className="sr-only">{KIND_LABEL[citation.kind]}: </span>
                        <cite className="not-italic">{citation.title}</cite>
                      </span>
                    )}

                    <span className="truncate font-mono text-[11px] text-muted-foreground">
                      {citation.origin}
                    </span>
                  </p>

                  <p className="mt-1.5 text-xs leading-relaxed">
                    <span className="font-medium text-muted-foreground">Supports: </span>
                    {citation.supports}
                  </p>

                  {citation.excerpt ? (
                    <blockquote className="mt-2 border-s-2 border-border ps-3 text-xs italic leading-relaxed text-muted-foreground">
                      {citation.excerpt}
                    </blockquote>
                  ) : null}

                  <p className="mt-2 text-[11px] text-muted-foreground">
                    Read{' '}
                    {citation.readAt ? (
                      <time dateTime={citation.readAt}>{citation.readLabel ?? citation.readAt}</time>
                    ) : (
                      'at answer time'
                    )}
                  </p>

                  {/* Said in words. A source that has moved on since it was
                      read is the most consequential thing on this card, and
                      an amber border does not communicate it. */}
                  {citation.stale ? (
                    <p className="mt-2 flex gap-1.5 rounded-lg bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-400">
                      <AlertTriangle aria-hidden className="mt-px h-3 w-3 shrink-0" />
                      {citation.stale}
                    </p>
                  ) : null}
                </div>
              </div>
            </li>
          )
        })}
      </ol>

      {footnote ? <p className="mt-3 text-xs text-muted-foreground">{footnote}</p> : null}
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/source-citation-list.tsx and import it where you need the section:

import { SourceCitationList } from '@/components/source-citation-list'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'Sources'
citationsCitation[]DEFAULT_CITATIONS
footnotestring—
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

source-citation-list.html
<!--
  Answer Source Citations — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block has no interactive behaviour, so nothing is missing.
-->
<section class="mx-auto w-full max-w-2xl p-6 ">
  <h3 class="mb-3 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-quote h-3.5 w-3.5" aria-hidden="true"><path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"></path><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"></path></svg>Sources<span class="font-mono normal-case tracking-normal">(4)</span></h3>
  <ol class="space-y-2">
    <li id="s5npw8-source-1" class="rounded-2xl border border-border/60 bg-card p-4">
      <div class="flex gap-3">
        <span aria-hidden="true" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-muted font-mono text-[11px] font-bold">1</span>
        <div class="min-w-0 flex-1">
          <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
              <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
              <path d="M3 5V19A9 3 0 0 0 21 19V5"></path>
              <path d="M3 12A9 3 0 0 0 21 12"></path>
            </svg>
            <span class="text-sm font-semibold">
              <span class="sr-only">Database table:</span>
              <cite class="not-italic">Q3 revenue cohorts</cite>
            </span>
            <span class="truncate font-mono text-[11px] text-muted-foreground">warehouse.arr_monthly</span>
          </p>
          <p class="mt-1.5 text-xs leading-relaxed"><span class="font-medium text-muted-foreground">Supports: </span>The 6.1% churn figure for accounts under 20 seats</p>
          <blockquote class="mt-2 border-s-2 border-border ps-3 text-xs italic leading-relaxed text-muted-foreground">2,463 accounts · lost ARR $1.24M · seat_band &lt; 20</blockquote>
          <p class="mt-2 text-[11px] text-muted-foreground">Read <time dateTime="2026-08-11T09:14:00Z">2 hours ago</time></p>
        </div>
      </div>
    </li>
    <li id="s5npw8-source-2" class="rounded-2xl border border-border/60 bg-card p-4">
      <div class="flex gap-3">
        <span aria-hidden="true" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-muted font-mono text-[11px] font-bold">2</span>
        <div class="min-w-0 flex-1">
          <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
              <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
              <path d="M3 5V19A9 3 0 0 0 21 19V5"></path>
              <path d="M3 12A9 3 0 0 0 21 12"></path>
            </svg>
            <span class="text-sm font-semibold">
              <span class="sr-only">Database table:</span>
              <cite class="not-italic">Activation funnel, Jul–Sep</cite>
            </span>
            <span class="truncate font-mono text-[11px] text-muted-foreground">warehouse.events</span>
          </p>
          <p class="mt-1.5 text-xs leading-relaxed"><span class="font-medium text-muted-foreground">Supports: </span>The claim that a second integration predicts retention</p>
          <blockquote class="mt-2 border-s-2 border-border ps-3 text-xs italic leading-relaxed text-muted-foreground">Accounts with ≥2 connected sources churned at 1.6%; with 1 source, 6.4%.</blockquote>
          <p class="mt-2 text-[11px] text-muted-foreground">Read <time dateTime="2026-08-11T09:15:00Z">2 hours ago</time></p>
        </div>
      </div>
    </li>
    <li id="s5npw8-source-3" class="rounded-2xl border border-border/60 bg-card p-4">
      <div class="flex gap-3">
        <span aria-hidden="true" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-muted font-mono text-[11px] font-bold">3</span>
        <div class="min-w-0 flex-1">
          <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
              <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
              <path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
              <path d="M10 9H8"></path>
              <path d="M16 13H8"></path>
              <path d="M16 17H8"></path>
            </svg>
            <span class="text-sm font-semibold">
              <span class="sr-only">Document:</span>
              <cite class="not-italic">Pricing handbook</cite>
            </span>
            <span class="truncate font-mono text-[11px] text-muted-foreground">docs/pricing-handbook.md</span>
          </p>
          <p class="mt-1.5 text-xs leading-relaxed"><span class="font-medium text-muted-foreground">Supports: </span>That the volume break applies per order rather than per quarter</p>
          <blockquote class="mt-2 border-s-2 border-border ps-3 text-xs italic leading-relaxed text-muted-foreground">The break is applied per order, not per quarter, so a single large order beats two smaller ones of the same total volume.</blockquote>
          <p class="mt-2 text-[11px] text-muted-foreground">Read <time dateTime="2026-04-02T11:00:00Z">4 months ago</time></p>
          <p class="mt-2 flex gap-1.5 rounded-lg bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700 dark:text-amber-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert mt-px h-3 w-3 shrink-0" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>This document has changed twice since it was indexed.</p>
        </div>
      </div>
    </li>
    <li id="s5npw8-source-4" class="rounded-2xl border border-border/60 bg-card p-4">
      <div class="flex gap-3">
        <span aria-hidden="true" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-muted font-mono text-[11px] font-bold">4</span>
        <div class="min-w-0 flex-1">
          <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
              <circle cx="12" cy="12" r="10"></circle>
              <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
              <path d="M2 12h20"></path>
            </svg>
            <a href="#" class="rounded text-sm font-semibold underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
              <span class="sr-only">Web page:</span>
              <cite class="not-italic">Supplier lead times</cite>
              <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link ms-1 inline h-3 w-3 align-baseline" aria-hidden="true">
                <path d="M15 3h6v6"></path>
                <path d="M10 14 21 3"></path>
                <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
              </svg>
              <span class="sr-only">(opens in a new tab)</span>
            </a>
            <span class="truncate font-mono text-[11px] text-muted-foreground">supplier.example.com</span>
          </p>
          <p class="mt-1.5 text-xs leading-relaxed"><span class="font-medium text-muted-foreground">Supports: </span>The 21-day reorder window</p>
          <p class="mt-2 text-[11px] text-muted-foreground">Read <time dateTime="2026-08-11T09:16:00Z">2 hours ago</time></p>
        </div>
      </div>
    </li>
  </ol>
  <p class="mt-3 text-xs text-muted-foreground">Every figure above traces to one of these. Nothing was inferred without a source.</p>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

8 more blocks in Retrieval & Context

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Retrieval & Context

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Retrieval & Context blocks

View category
Open the full page for this block

Retrieved Context Chunks

The RAG debugging surface: ranked passages with similarity as a meter, matched spans in real mark elements, and filter chips wired as a radiogroup so arrows move between them.

Retrieval & Context327 lines1 dep
Open the full page for this block

Knowledge Source Picker

Choosing what the assistant may read, with indexing state told honestly — a half-indexed source says so rather than showing a green tick — and the footer stating the effect of the selection, not its cardinality.

Retrieval & Context345 lines1 dep
Open the full page for this block

Nothing Found, Answered Honestly

The refusal screen: says plainly that nothing was found, shows where it looked and the near-misses below threshold, and offers the two real ways out instead of inventing an answer.

Retrieval & Context180 lines1 dep
Open the full page for this block

Context Window Budget

What is in the context and what fell out of it — a stacked budget with the reply reserve drawn as a segment, and the dropped chunks named with the reason, which is the part nobody ships.

Retrieval & Context309 lines1 dep
Open the full page for this block

Index Freshness Panel

A green tick means the connector works, not that the answers are current. Freshness as an age in words per source, the headline taken from the stalest one, and a failed sync that still says what age it is answering from.

Retrieval & Context246 lines1 dep
Open the full page for this block

Retrieval Freshness List

Retrieved sources ranked by how stale they are, because a confident answer from a two-year-old document is the failure people do not catch.

Retrieval & Context151 linesNo deps
Open the full page for this block

Context Scope List

Which sources this answer may draw on, shown as a scope the user can read before asking rather than a citation list after.

Retrieval & Context152 linesNo deps
Open the full page for this block

Grounding Split

How an answer is tied to its sources, and what the system does when it cannot find any.

Retrieval & Context156 linesNo deps