Grounded Answer

AI / agent

An answer that shows its work: per-claim citation chips light up the exact source quote, contradictions are surfaced inline instead of averaged away, and uncovered ground is stated.

Question

What is our refund SLA for UPI payments?

Refunds for UPI payments must be initiated within 24 hours of the customer request. 95%
Once initiated, the credit reaches the customer in 5–7 working days. 88%

Sources disagree: The 2023 help-centre page still says 3–5 days; the 2025 policy supersedes it.

For failed (not refunded) UPI transactions, auto-reversal is required within T+1 day. 72%

Not covered by any source: No source covers refunds for international UPI (UPI One World).

Sources

  1. 1Refund policy v4 (2025)

    UPI refunds: initiate ≤ 24h from request; settlement 5–7 working days.

    policies/refunds.md · updated 12 Mar 2025

  2. 2Help centre — refunds

    You'll usually see the money back in 3–5 business days.

    help.acme.in/refunds · 2023, stale

  3. 3NPCI circular 129

    Failed transactions shall be auto-reversed within T+1.

    external · applies to failures, not refunds

Confidence is per claim, not per answer — low-confidence claims are tinted.

Install

npx shadcn@latest add https://labs.duku.design/r/grounded-answer.json

Ask your agent to use this

Connect DUKU Labs to MCP, then hand your coding agent this prompt:

Use the DUKU Labs MCP server to install the Grounded Answer component (get_component "grounded-answer", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.

Interaction

TryWhat happens
MountClaims blur in sequentially like a considered answer
Click a citation chipThe matching source card highlights and pulses with its exact quote
Read claim 2A contradiction note: the 2023 help page disagrees with the 2025 policy
Check the dashed boxWhat no source covers is said plainly, not guessed at

Props

PropTypeDefaultDescription
question / claims / sourcesstring / AnswerClaim[] / AnswerSource[]DEMO_ANSWERClaims carry sourceIds, confidence and optional contradiction.
missingEvidencestring[]What the sources don't cover.
onFeedback(helpful: boolean) => voidHelpful / not helpful.
Source — registry/default/ai/grounded-answer.tsx
"use client";

import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";

/* ------------------------------------------------------------------ */
/* Model                                                                */
/* ------------------------------------------------------------------ */

export interface AnswerSource {
  id: string;
  title: string;
  quote: string;
  meta: string;
}

export interface AnswerClaim {
  text: string;
  sourceIds: string[];
  /** Grounding confidence 0–1; low values render with a caution tint. */
  confidence: number;
  /** Set when sources disagree — surfaced, never averaged away. */
  contradiction?: string;
}

export interface GroundedAnswerProps
  extends React.HTMLAttributes<HTMLDivElement> {
  question: string;
  claims?: AnswerClaim[];
  sources?: AnswerSource[];
  missingEvidence?: string[];
  onFeedback?: (helpful: boolean) => void;
  ref?: React.Ref<HTMLDivElement>;
}

export const DEMO_ANSWER: Pick<
  GroundedAnswerProps,
  "question" | "claims" | "sources" | "missingEvidence"
> = {
  question: "What is our refund SLA for UPI payments?",
  claims: [
    {
      text: "Refunds for UPI payments must be initiated within 24 hours of the customer request.",
      sourceIds: ["s1"],
      confidence: 0.95,
    },
    {
      text: "Once initiated, the credit reaches the customer in 5–7 working days.",
      sourceIds: ["s1", "s2"],
      confidence: 0.88,
      contradiction:
        "The 2023 help-centre page still says 3–5 days; the 2025 policy supersedes it.",
    },
    {
      text: "For failed (not refunded) UPI transactions, auto-reversal is required within T+1 day.",
      sourceIds: ["s3"],
      confidence: 0.72,
    },
  ],
  sources: [
    { id: "s1", title: "Refund policy v4 (2025)", quote: "UPI refunds: initiate ≤ 24h from request; settlement 5–7 working days.", meta: "policies/refunds.md · updated 12 Mar 2025" },
    { id: "s2", title: "Help centre — refunds", quote: "You'll usually see the money back in 3–5 business days.", meta: "help.acme.in/refunds · 2023, stale" },
    { id: "s3", title: "NPCI circular 129", quote: "Failed transactions shall be auto-reversed within T+1.", meta: "external · applies to failures, not refunds" },
  ],
  missingEvidence: ["No source covers refunds for international UPI (UPI One World)."],
};

/* ------------------------------------------------------------------ */
/* Answer                                                               */
/* ------------------------------------------------------------------ */

/**
 * An answer that shows its work: every claim carries citation chips that
 * light up the exact source and quote, contradictions are called out
 * inline instead of averaged away, and what the sources don't cover is
 * stated plainly.
 */
export function GroundedAnswer({
  question,
  claims = DEMO_ANSWER.claims!,
  sources = DEMO_ANSWER.sources!,
  missingEvidence = DEMO_ANSWER.missingEvidence!,
  onFeedback,
  className,
  ref,
  ...rest
}: GroundedAnswerProps) {
  const reduced = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  const [activeSource, setActiveSource] = React.useState<string | null>(null);
  const [feedback, setFeedback] = React.useState<null | boolean>(null);
  const srcIndex = Object.fromEntries(sources.map((s, i) => [s.id, i + 1]));

  /* Claims blur in sequentially, like a considered answer. */
  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;
      const rows = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-claim]"));
      if (reduced) {
        gsap.set(rows, { y: 0, opacity: 1 });
        return;
      }
      gsap.fromTo(
        rows,
        { y: 10, opacity: 0, filter: "blur(5px)" },
        { y: 0, opacity: 1, filter: "blur(0px)", duration: 0.4, ease: "power3.out", stagger: 0.12, clearProps: "filter" }
      );
    },
    { scope: rootRef }
  );

  /* Selecting a citation pulses the matching source card. */
  const focusSource = (id: string) => {
    setActiveSource((s) => (s === id ? null : id));
    if (reduced || !rootRef.current) return;
    const card = rootRef.current.querySelector<HTMLElement>(`[data-source="${id}"]`);
    if (card)
      gsap.fromTo(card, { scale: 1 }, { scale: 1.02, duration: 0.15, yoyo: true, repeat: 1, ease: "power2.out" });
  };

  return (
    <div
      ref={rootRef}
      data-slot="grounded-answer"
      className={cn(
        "w-full max-w-xl rounded-2xl border border-border bg-card p-4",
        className
      )}
      {...rest}
    >
      <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
        Question
      </p>
      <p className="text-sm font-medium text-foreground">{question}</p>

      {/* Claims */}
      <div className="mt-3 flex flex-col gap-2">
        {claims.map((c, i) => (
          <div
            key={i}
            data-claim
            className={cn(
              "rounded-lg px-3 py-2 text-[13px] leading-6",
              c.confidence < 0.8 ? "bg-warning/5" : "bg-muted/40"
            )}
          >
            <span className="text-foreground">{c.text}</span>{" "}
            {c.sourceIds.map((sid) => (
              <button
                key={sid}
                type="button"
                aria-pressed={activeSource === sid}
                aria-label={`Show source ${srcIndex[sid]}`}
                onClick={() => focusSource(sid)}
                className={cn(
                  "mx-0.5 inline-flex size-4 items-center justify-center rounded align-text-top text-[9px] font-semibold transition-colors duration-200",
                  activeSource === sid
                    ? "bg-primary text-primary-foreground"
                    : "bg-info/15 text-info hover:bg-info/25",
                  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                )}
              >
                {srcIndex[sid]}
              </button>
            ))}
            <span
              className={cn(
                "ml-1 align-middle text-[10px] tabular-nums",
                c.confidence < 0.8 ? "font-medium text-warning" : "text-muted-foreground"
              )}
            >
              {(c.confidence * 100).toFixed(0)}%
            </span>
            {c.contradiction ? (
              <p className="mt-1 border-l-2 border-warning pl-2 text-[11px] leading-4 text-warning">
                Sources disagree: {c.contradiction}
              </p>
            ) : null}
          </div>
        ))}
      </div>

      {/* Missing evidence */}
      {missingEvidence.length > 0 ? (
        <p className="mt-2 rounded-lg border border-dashed border-border px-3 py-1.5 text-[11px] leading-4 text-muted-foreground">
          <span className="font-medium text-foreground">Not covered by any source:</span>{" "}
          {missingEvidence.join(" ")}
        </p>
      ) : null}

      {/* Sources */}
      <p className="mt-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
        Sources
      </p>
      <ol className="mt-1.5 flex flex-col gap-1.5">
        {sources.map((s) => (
          <li
            key={s.id}
            data-source={s.id}
            className={cn(
              "rounded-xl border px-3 py-2 transition-colors duration-300",
              activeSource === s.id ? "border-primary bg-primary/5" : "border-border bg-background"
            )}
          >
            <p className="flex items-baseline gap-1.5 text-xs font-medium text-foreground">
              <span className="flex size-4 shrink-0 items-center justify-center rounded bg-info/15 text-[9px] font-semibold text-info">
                {srcIndex[s.id]}
              </span>
              {s.title}
            </p>
            <p className="mt-0.5 text-[11px] italic leading-4 text-muted-foreground">
              “{s.quote}”
            </p>
            <p className="mt-0.5 text-[10px] text-muted-foreground">{s.meta}</p>
          </li>
        ))}
      </ol>

      {/* Feedback */}
      <div className="mt-3 flex items-center justify-between border-t border-border pt-2">
        <span className="text-[10px] text-muted-foreground">
          Confidence is per claim, not per answer — low-confidence claims are tinted.
        </span>
        <div className="flex gap-1" aria-live="polite">
          {feedback === null ? (
            (["yes", "no"] as const).map((v) => (
              <button
                key={v}
                type="button"
                aria-label={v === "yes" ? "Helpful" : "Not helpful"}
                onClick={() => {
                  setFeedback(v === "yes");
                  onFeedback?.(v === "yes");
                }}
                className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground transition-colors duration-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {v === "yes" ? "Helpful" : "Not helpful"}
              </button>
            ))
          ) : (
            <span className="text-[11px] text-success">Thanks — recorded.</span>
          )}
        </div>
      </div>
    </div>
  );
}