Source List UI
The passages retrieval actually returned, with similarity scores and a visible relevance floor — including the case where nothing cleared it.
npx shadcn@latest add https://scrimui.dev/r/source-list.jsonAgent promptClaude Code · Cursor · any agent
Add the Source List UI component from the Scrim UI registry to this project and use it in the configuration described below.
Source List UI — The passages retrieval actually returned, with similarity scores and a visible relevance floor — including the case where nothing cleared it.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/source-list.json
```
This writes a single file to `components/ui/source-list.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/source-list to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { SourceList } from "@/components/ui/source-list";
// Every candidate considered, not only the ones that passed — the panel is
// what lets you tell "nothing relevant existed" from "the model made it up".
const SOURCES = retrieved; // [{ id, title, passage, score }, …]
<SourceList
sources={SOURCES}
floor={0.35}
onOpen={(id) => scrollToPassage(id)}
/>
```
## 3. The configuration that matters
This is the component's "Ranked" state — Two passages cleared 0.35 and three did not. The three are one click away, because their scores are how you tune the floor.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
Reference: https://scrimui.dev/components/source-listFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Presets
Props
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
"use client";
import * as React from "react";
/**
* What retrieval actually returned — including what it returned and threw away.
*
* Most source panels show the top k passages and stop. The two things worth
* building are the ones that get left out:
*
* **The floor has to be visible.** A retrieval system with no relevance floor
* always returns something, and "something" for a question the corpus does
* not answer is the closest match to a question nobody asked. Showing the
* floor, and the candidates that fell under it, is what turns "the model made
* this up" into "nothing relevant was found and it answered anyway" — two
* different bugs, in two different files.
*
* **Nothing found is a state, not an empty list.** It is the state that makes
* a RAG system trustworthy: no chunk cleared the floor, no model call, a
* fixed "it is not in these documents". Rendering it as a blank panel throws
* away the one moment the system was behaving well.
*
* The scores are shown because someone is always tuning the floor, and a
* floor set without looking at the distribution it is cutting is a number
* somebody guessed.
*/
export type RetrievedSource = {
id: string;
/** Document name, section, page — whatever locates it for a human. */
title: string;
passage: string;
/** Similarity, 0–1. */
score: number;
};
export type SourceListProps = {
/** Every candidate considered, not only the ones that passed. */
sources: RetrievedSource[];
/** Below this, a passage was not sent to the model. */
floor?: number;
/** Jump to the passage in the document. */
onOpen?: (id: string) => void;
className?: string;
};
function ChevronIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function Row({
source,
n,
below,
onOpen,
}: {
source: RetrievedSource;
n?: number;
below: boolean;
onOpen?: (id: string) => void;
}) {
const body = (
<>
<div className="flex items-baseline gap-2">
{n !== undefined && (
<span className="flex h-[1.35em] min-w-[1.35em] shrink-0 items-center justify-center rounded-[0.3em] bg-amber-100 px-[0.3em] text-[11px] font-medium text-amber-900 tabular-nums dark:bg-amber-400/20 dark:text-amber-200">
{n}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100">
{source.title}
</span>
<span
className={`shrink-0 tabular-nums text-[11px] ${
below ? "text-zinc-400 dark:text-zinc-500" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{source.score.toFixed(3)}
</span>
</div>
<p
className={`mt-1 line-clamp-2 text-[12px] leading-5 ${
below ? "text-zinc-400 dark:text-zinc-500" : "text-zinc-600 dark:text-zinc-300"
}`}
>
{source.passage}
</p>
</>
);
if (!onOpen) {
return <div className="px-3.5 py-2.5">{body}</div>;
}
return (
<button
type="button"
onClick={() => onOpen(source.id)}
className="block w-full px-3.5 py-2.5 text-left transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
>
{body}
</button>
);
}
export function SourceList({ sources, floor = 0, onOpen, className = "" }: SourceListProps) {
const [showBelow, setShowBelow] = React.useState(false);
/* Sorted here rather than trusted from the caller: a panel whose order
disagrees with its own score column is the kind of bug nobody reports
and everybody stops trusting. */
const ranked = React.useMemo(() => [...sources].sort((a, b) => b.score - a.score), [sources]);
const passed = ranked.filter((s) => s.score >= floor);
const below = ranked.filter((s) => s.score < floor);
return (
<div className={`overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>
<div className="flex items-baseline gap-2 border-b border-zinc-100 px-3.5 py-2.5 dark:border-zinc-800">
<span className="text-[11px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Retrieved
</span>
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">
{passed.length} of {ranked.length} candidates
</span>
{floor > 0 && (
<span className="ml-auto tabular-nums text-[11px] text-zinc-400 dark:text-zinc-500">
floor {floor.toFixed(2)}
</span>
)}
</div>
{passed.length === 0 ? (
/* The good state, rendered as such. Nothing cleared the floor, so no
model call was made and the answer is a fixed sentence — which is
the behaviour that makes the rest of the system worth trusting. */
<div className="px-3.5 py-4">
<p className="text-[13px] font-medium text-zinc-800 dark:text-zinc-100">
Nothing cleared the floor.
</p>
<p className="mt-1 text-[12px] leading-5 text-zinc-500 dark:text-zinc-400">
The closest candidate scored {ranked[0]?.score.toFixed(3) ?? "—"}. No model call was
made — an answer built from these passages would have been invented.
</p>
</div>
) : (
<div className="divide-y divide-zinc-100 dark:divide-zinc-800/80">
{passed.map((s, i) => (
<Row key={s.id} source={s} n={i + 1} below={false} onOpen={onOpen} />
))}
</div>
)}
{below.length > 0 && (
<div className="border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={() => setShowBelow((v) => !v)}
aria-expanded={showBelow}
className="flex w-full items-center gap-1.5 px-3.5 py-2 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-50 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800/50 dark:hover:text-zinc-200"
>
<ChevronIcon className={showBelow ? "rotate-180" : ""} />
{below.length} below the floor — not sent to the model
</button>
{showBelow && (
<div className="divide-y divide-zinc-100 bg-zinc-50/60 dark:divide-zinc-800/80 dark:bg-zinc-800/20">
{below.map((s) => (
<Row key={s.id} source={s} below onOpen={onOpen} />
))}
</div>
)}
</div>
)}
</div>
);
}
When to use it
- Pass every candidate retrieval considered, not only the survivors. The panel's job is to separate 'nothing relevant existed' from 'the model invented it', and it cannot do that from the survivors alone.
- Apply the floor before the model call, not after. A candidate under the floor is not a weak source, it is not a source, and sending it invites a confident answer built on it.
- Show the scores. Somebody is always tuning the floor, and a threshold chosen without seeing the distribution it cuts is a number that was guessed.
- Number the rows to match the citation markers in the answer. The number is what the reader matches on, and two different numbering schemes are worse than none.
- Keep the empty state a real answer. No chunk cleared the floor, no model call, a fixed sentence — that is the system working, and it deserves better than a blank panel.
What breaks in production
- Showing only the top k. The interesting question is what came sixth and how close it was, which is exactly what gets thrown away.
- Rendering 'no sources' as an empty box. The one moment the system refused to make something up looks identical to a component that failed to load.
- Sorting by document order or by title. The panel then disagrees with its own score column, and the reader stops trusting both.
- Hiding the below-floor candidates entirely. The floor becomes untunable, because nothing on screen says what it is cutting.
- Letting the passage snippet grow to full height. Five expanded passages push the answer off the screen, and the panel is a reference, not the content.
Related Components
A cited source as a card — favicon, page title, domain, and the snippet the answer actually drew from.
Inline citation markers in generated text — numbered footnotes that reveal the source in a hover preview card.
A citation marker that shows the passage it came from — fixed positioning that survives a scrolling answer, touch and keyboard, and an honest state for a number the model invented.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.