Grounded Answer
AI / agentAn 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?
Sources disagree: The 2023 help-centre page still says 3–5 days; the 2025 policy supersedes it.
Not covered by any source: No source covers refunds for international UPI (UPI One World).
Sources
1Refund policy v4 (2025)
“UPI refunds: initiate ≤ 24h from request; settlement 5–7 working days.”
policies/refunds.md · updated 12 Mar 2025
2Help centre — refunds
“You'll usually see the money back in 3–5 business days.”
help.acme.in/refunds · 2023, stale
3NPCI circular 129
“Failed transactions shall be auto-reversed within T+1.”
external · applies to failures, not refunds
Install
npx shadcn@latest add https://labs.duku.design/r/grounded-answer.jsonAsk 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
| Try | What happens |
|---|---|
| Mount | Claims blur in sequentially like a considered answer |
| Click a citation chip | The matching source card highlights and pulses with its exact quote |
| Read claim 2 | A contradiction note: the 2023 help page disagrees with the 2025 policy |
| Check the dashed box | What no source covers is said plainly, not guessed at |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| question / claims / sources | string / AnswerClaim[] / AnswerSource[] | DEMO_ANSWER | Claims carry sourceIds, confidence and optional contradiction. |
| missingEvidence | string[] | — | What the sources don't cover. |
| onFeedback | (helpful: boolean) => void | — | Helpful / 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>
);
}