A retrieved-chunks panel with relevance bars that scale in on view and rows that expand to the retrieved passage, each tagged with a source-origin chip.
npx shadcn@latest add @paragon/rag-sources-panel"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ChevronRight, FileText } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RagSource {
id: string;
title: string;
/** Domain or file origin, shown as a chip, e.g. "docs.stripe.com". */
origin: string;
/** Relevance score, 0–1. */
score: number;
/** The retrieved passage, revealed on expand. */
passage: string;
}
export interface RagSourcesPanelProps extends React.ComponentProps<"div"> {
sources: RagSource[];
/** Header count label. Defaults to "N sources". */
heading?: string;
static?: boolean;
}
function scoreColor(score: number): string {
if (score >= 0.75) return "var(--color-success)";
if (score >= 0.5) return "oklch(0.76 0.13 85)";
return "var(--color-muted-foreground)";
}
/**
* A retrieved-sources panel for RAG. Each chunk shows a title, a source-origin
* chip, and a relevance bar that scales in from the left on first view,
* staggered top-to-bottom (transform only). A row expands to reveal the
* retrieved passage through a grid-rows transition (0fr↔1fr). Relevance is
* also exposed as tabular text and aria. Reduced motion fills bars immediately.
*/
export function RagSourcesPanel({
sources,
heading,
static: isStatic = false,
className,
...props
}: RagSourcesPanelProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const filled = !animate || inView;
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const sorted = React.useMemo(
() => [...sources].sort((a, b) => b.score - a.score),
[sources],
);
return (
<div
ref={ref}
data-slot="rag-sources-panel"
className={cn(
"flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-medium text-foreground">
{heading ?? "Retrieved sources"}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{sources.length}
</span>
</div>
<ul className="flex flex-col">
{sorted.map((src, i) => {
const isOpen = expanded.has(src.id);
return (
<li key={src.id} className="border-b last:border-b-0">
<button
type="button"
aria-expanded={isOpen}
onClick={() => toggle(src.id)}
className="flex w-full flex-col gap-2 px-4 py-3 text-left transition-colors duration-(--duration-fast) hover:bg-accent/40"
>
<div className="flex items-center gap-2">
<FileText
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{src.title}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{(src.score * 100).toFixed(0)}%
</span>
<ChevronRight
aria-hidden
className={cn(
"size-3.5 shrink-0 text-muted-foreground",
!isStatic &&
"transition-transform duration-(--duration-fast) ease-out",
isOpen && "rotate-90",
)}
/>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex shrink-0 items-center rounded-full bg-secondary px-1.5 py-0.5 text-[11px] text-muted-foreground">
{src.origin}
</span>
<span
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(src.score * 100)}
aria-label={`Relevance ${(src.score * 100).toFixed(0)}%`}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary"
>
<span
className="block h-full origin-left rounded-full"
style={{
width: `${src.score * 100}%`,
background: scoreColor(src.score),
transform: filled ? "scaleX(1)" : "scaleX(0)",
transition: animate
? `transform 520ms var(--ease-out) ${i * 80}ms`
: undefined,
}}
/>
</span>
</div>
</button>
<div
className={cn(
"grid px-4",
!isStatic &&
"transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none",
)}
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
>
<div inert={!isOpen} className="min-h-0 overflow-hidden">
<p className="border-l-2 border-border py-1 pb-3 pl-3 text-xs leading-relaxed text-muted-foreground">
{src.passage}
</p>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}