Skip to content
KONIGI

Source list

The answer drew on eight pages, and the viewer needs to judge those pages before trusting it.

Updated September 12, 2026

Problem

An answer is only as good as what it was built from. The reader can judge a set of sources in about four seconds, far faster than they can fact-check a paragraph, but only if the sources are visible as a set rather than scattered through the prose one marker at a time.

Solution

Show the material the answer was assembled from as a list, with enough per row to judge it without clicking: title, domain, and date. Domain does most of the work. A reader distinguishes a standards body from a vendor blog from a forum post instantly, and that single judgement calibrates how they read everything below.

Placement is the decision that separates the two schools. Putting the list after the answer treats it as a bibliography, consulted by the small fraction of readers who go looking. Putting it before the answer treats it as a premise, and every reader sees it. The second is stronger for anything factual, because calibration is only useful in advance. A reader who learns after the fact that the answer came from three SEO listicles has already absorbed it.

The list has to be honest about a distinction the system knows and the interface usually hides: what was retrieved against what was used. A retrieval step may pull twenty candidates and the generation may draw on four. Showing all twenty pads the appearance of rigour with material the answer ignored. Showing four hides how much was rejected and how thin the basis actually was. Naming both counts is cheap and more truthful than either alone.

Rows need states as much as content. A dead link, a paywall, a document the viewer lacks permission to open, and a source behind a login are all common, and all of them should be visible before the click rather than after. In an enterprise retrieval system the permission case is the sharp one: an answer built on a document the reader can’t open is unverifiable by them specifically, and saying so is better than a link that fails.

NN/g’s finding about site chatbots applies to the whole list: people came for an answer—not for a reading assignment. Four to eight rows, compact, with the rest behind an expander.

Use when

The system retrieves before generating, whether from the web, an attached document, or an internal corpus.

Don’t use when

The answer is generated from the viewer’s own supplied text or from the model’s parameters alone. A list of sources under a rewrite of the viewer’s paragraph is theatre.

Trade-offs

Sources before the answer delay the thing people came for and make the product feel like a search engine, which is correct for an answer engine and wrong for an assistant drafting an email. Rich rows with snippets support judgement and take vertical space proportional to source count. Showing the full retrieved set is more honest and reads as noise. And the list creates an obligation: once a product displays its sources it’ll be judged on their quality, which is a good pressure and a real cost.

Checklist

  • Is the list visible before the answer is read, or only after?
  • Does each row carry a domain and a date without a click?
  • Can the reader tell what was retrieved against what was actually used?
  • Are paywalled, dead, and permission-restricted sources marked before the click?
  • How many rows show before an expander, and what’s the ordering?
  • Does the ordering reflect how much each source contributed, or arrival order?
  • What does the list look like when retrieval returned nothing good?
  • Does the answer say when it went beyond its sources?
  • Is the list included when the answer is copied or shared?
  • Do the rows survive a narrow viewport without truncating the domain?

Compare

Perplexity puts the sources above the prose as a horizontal strip and treats them as the primary object. The answer reads as a synthesis of a visible corpus rather than an opinion with footnotes. ChatGPT attaches sources when a search ran and keeps them subordinate to the answer, preserving the assistant framing at the cost of later calibration. Claude surfaces project knowledge and attached documents as the basis, so the list describes a corpus the viewer assembled rather than one the system found. Microsoft Copilot lists internal files from the tenant, where a row’s real information is often whether the reader can open it at all.

Citation chip is the per-claim form and answers the narrower question. Tool call trace is the record of the retrieval that produced the list. Attachment tray is the viewer-supplied end of the same pipeline. Scoped context is what determines which corpus was searched. Reasoning disclosure covers the step between retrieving and writing.

Source list anatomy A row of sources placed above the answer rather than below it, each carrying a domain and a date, one marked as paywalled and one as permission-restricted, with a line stating how many candidates were retrieved against how many were used. Before the answer, so calibration comes first prometheus.io Jan 2026 nngroup.com Apr 2026 wsj.com paywalled finance/Q3.xlsx no access retrieved 20, used 4 the answer, read with the sources already in mind 1 3 2 4 1 DOMAIN DOES THE WORK A standards body, a vendor blog and a forum post are told apart instantly. 2 RETRIEVED VERSUS USED Showing all twenty pads the rigour. Showing four hides how thin the basis was. 3 UNREACHABLE SOURCES Mark a paywall or a permission wall before the click, not after it fails. 4 ABOVE, NOT BELOW Calibration is only useful in advance. A bibliography is read by almost nobody. Once a product shows its sources it gets judged on them, which is the right pressure to accept.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Sources placed above the answer so calibration comes first. Each carries a domain and a date, a paywalled one and an inaccessible one are marked, and the count says how many candidates were retrieved against how many were used.

shadcn
npx shadcn@latest add button
npm
lucide-react
Tokens
--card-foreground--muted--muted-foreground--border--status-warn--chart-1

retrieved 20, used 4

Report latency at p50, p95 and p99 rather than as a mean, since the tail is what pages someone, and put the comparison window beside every figure on the tile. The sector benchmark is taken from the WSJ piece's summary only, and the Q3 spreadsheet could not be read, so the infrastructure line below is from last quarter's figures.

SourceList.tsxRows with a domain, a date and a closed access state, the retrieved-versus-used count, and the answer as children so it always renders below.

import { useState, type ReactNode } from "react";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

/**
 * A row is judged before the click, so the things that decide the judgement
 * are required: the domain, the date, and whether the reader can open it at
 * all. `access` is a closed set because each value wants a different mark and
 * a different explanation, and "unavailable" would cover none of them.
 */
export type Access = "open" | "paywalled" | "no-access" | "dead";

export type Source = {
  title: string;
  /** Host, or a path for a document in the viewer's own corpus. */
  domain: string;
  /** As the source states it: "Jan 2026". */
  date: string;
  url: string;
  access: Access;
};

const ACCESS_LABEL: Record<Exclude<Access, "open">, string> = {
  paywalled: "paywalled",
  "no-access": "no access",
  dead: "link dead",
};

export function SourceList({ sources, retrieved, max = 8, onOpen, children }: {
  /** What the answer drew on, most-used first. */
  sources: Source[];
  /** How many candidates retrieval returned before generation chose. Required:
   *  the used count alone hides how thin the basis was. */
  retrieved: number;
  /** Rows shown before the expander. */
  max?: number;
  onOpen?: (source: Source) => void;
  /** The answer. It renders below the list, so the reader is calibrated first. */
  children: ReactNode;
}) {
  const [expanded, setExpanded] = useState(false);
  const shown = expanded ? sources : sources.slice(0, max);
  const used = sources.length;

  return (
    <div>
      <ul className="grid grid-cols-2 gap-3 sm:grid-cols-4" aria-label="sources">
        {shown.map((s) => {
          const gated = s.access !== "open";
          return (
            <li key={s.url}>
              <a
                href={s.url}
                target="_blank"
                rel="noreferrer"
                onClick={() => onOpen?.(s)}
                title={s.title}
                className={cn("block rounded-md border px-3 py-2 no-underline", gated && "border-status-warn")}
              >
                <p className="truncate text-[11px] text-card-foreground">{s.domain}</p>
                <p className={cn("mt-1 flex items-center gap-1 text-[11px]", gated ? "text-status-warn" : "text-muted-foreground")}>
                  {gated && <Lock className="size-2.5" aria-hidden="true" />}
                  {gated ? ACCESS_LABEL[s.access as Exclude<Access, "open">] : s.date}
                </p>
              </a>
            </li>
          );
        })}
      </ul>

      <div className="mt-4 flex items-center gap-4">
        <p className="text-[11px] tabular-nums text-muted-foreground">retrieved {retrieved}, used {used}</p>
        <span role="meter" aria-valuemin={0} aria-valuemax={retrieved} aria-valuenow={used} aria-label="sources used of those retrieved"
          className="flex h-2 w-[200px] overflow-hidden rounded-full bg-muted">
          <span className="bg-chart-1" style={{ width: `${(100 * used) / retrieved}%` }} />
        </span>
        {sources.length > max && (
          <Button variant="link" size="sm" className="ml-auto h-auto p-0 text-[11px]" onClick={() => setExpanded((e) => !e)}>
            {expanded ? "fewer" : `${sources.length - max} more`}
          </Button>
        )}
      </div>

      <div className="mt-4 border-t pt-4">{children}</div>
    </div>
  );
}

demo.tsxHow it is called: four sources used of twenty retrieved, one paywalled and one the viewer cannot open, above the answer.

import { SourceList, type Source } from "./SourceList";

/**
 * Four sources used out of twenty retrieved, above the answer they produced.
 * Two are open, one is behind a paywall, and the figures spreadsheet is one
 * the viewer cannot open, which the answer then has to admit.
 */
const SOURCES: Source[] = [
  { title: "Histograms and summaries", domain: "prometheus.io", date: "Jan 2026", url: "https://prometheus.io/docs/practices/histograms/", access: "open" },
  { title: "Dashboards: making charts and graphs easier to understand", domain: "nngroup.com", date: "Apr 2026", url: "https://www.nngroup.com/articles/dashboards-preattentive/", access: "open" },
  { title: "Cloud spending rises as software firms chase AI capacity", domain: "wsj.com", date: "Aug 2026", url: "https://www.wsj.com/tech/ai/cloud-spending-ai-capacity", access: "paywalled" },
  { title: "Q3 figures", domain: "finance/Q3.xlsx", date: "Sep 2026", url: "drive://finance/Q3.xlsx", access: "no-access" },
];

export default function Demo() {
  return (
    <div className="rounded-lg border bg-card p-4">
      <SourceList sources={SOURCES} retrieved={20} onOpen={(s) => console.log("opened", s.domain)}>
        <p className="text-sm leading-relaxed text-card-foreground">
          Report latency at p50, p95 and p99 rather than as a mean, since the tail is what pages someone, and put the comparison window beside every figure on the tile. The sector benchmark is taken from the WSJ piece's summary only, and the Q3 spreadsheet could not be read, so the infrastructure line below is from last quarter's figures.
        </p>
      </SourceList>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.