AI & Chat

RAG Sources Panel

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.

Install

npx shadcn@latest add @paragon/rag-sources-panel

rag-sources-panel.tsx

"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>
  );
}