text
Source Trust Card
A paginated single-source card with a trust badge and domain pills for jumping between sources.
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { ChevronLeft, ChevronRight, Globe, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TrustedSource {
title: string;
domain: string;
description: string;
url: string;
/** Falls back to a globe icon when omitted or when the image fails to load. */
faviconUrl?: string;
/** Shows a "Trusted" badge; hovering or focusing it reveals why the source is trusted. */
trustReason?: string;
}
export interface SourceTrustCardProps {
sources: TrustedSource[];
defaultIndex?: number;
className?: string;
}
export function SourceTrustCard({ sources, defaultIndex = 0, className }: SourceTrustCardProps) {
const [index, setIndex] = React.useState(defaultIndex);
const [direction, setDirection] = React.useState(0);
const source = sources[index];
function go(delta: number) {
setDirection(delta);
setIndex((i) => (i + delta + sources.length) % sources.length);
}
function goTo(i: number) {
setDirection(i > index ? 1 : -1);
setIndex(i);
}
return (
<div className={cn("w-full max-w-sm overflow-hidden rounded-2xl border bg-card shadow-sm", className)}>
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => go(-1)}
disabled={sources.length < 2}
aria-label="Previous source"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ChevronLeft className="size-4" />
</button>
<span className="min-w-[3ch] text-center text-xs tabular-nums text-muted-foreground">
{index + 1}/{sources.length}
</span>
<button
type="button"
onClick={() => go(1)}
disabled={sources.length < 2}
aria-label="Next source"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<ChevronRight className="size-4" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{sources.length} source{sources.length === 1 ? "" : "s"}
</span>
</div>
<div className="relative overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={index}
initial={{ x: direction >= 0 ? 24 : -24, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: direction >= 0 ? -24 : 24, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="p-3"
>
<div className="relative">
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="-m-1 flex items-start gap-2.5 rounded-lg p-1 transition-colors hover:bg-accent/50"
>
<Favicon source={source} />
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-xs text-muted-foreground", source.trustReason && "pr-16")}>
{source.domain}
</span>
<span className="mt-0.5 block line-clamp-2 text-sm font-medium leading-snug text-foreground">
{source.title}
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{source.description}
</span>
</span>
</a>
{source.trustReason && (
<TrustBadge domain={source.domain} reason={source.trustReason} url={source.url} />
)}
</div>
</motion.div>
</AnimatePresence>
</div>
{sources.length > 1 && (
<div className="flex flex-wrap gap-1.5 border-t px-3 py-2">
{sources.map((s, i) => (
<button
key={s.url}
type="button"
onClick={() => goTo(i)}
aria-current={i === index}
className={cn(
"rounded-full border px-2 py-0.5 text-xs transition-colors",
i === index
? "border-primary/30 bg-primary/10 text-foreground"
: "border-transparent bg-muted text-muted-foreground hover:bg-accent"
)}
>
{s.domain}
</button>
))}
</div>
)}
</div>
);
}
function Favicon({ source }: { source: TrustedSource }) {
const [errored, setErrored] = React.useState(false);
const showImage = source.faviconUrl && !errored;
return (
<span className="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted">
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={source.faviconUrl} alt="" className="size-full object-cover" onError={() => setErrored(true)} />
) : (
<Globe className="size-4 text-muted-foreground" aria-hidden />
)}
</span>
);
}
const TOOLTIP_WIDTH = 224;
const MARGIN = 8;
interface Position {
top: number;
left: number;
placement: "top" | "bottom";
}
function TrustBadge({ domain, reason, url }: { domain: string; reason: string; url: string }) {
const [open, setOpen] = React.useState(false);
const [position, setPosition] = React.useState<Position | null>(null);
const mounted = useMounted();
const triggerRef = React.useRef<HTMLButtonElement>(null);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const tooltipId = React.useId();
const reposition = React.useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
const placement: Position["placement"] = rect.top > 140 ? "top" : "bottom";
const left = Math.min(
Math.max(rect.right, TOOLTIP_WIDTH + MARGIN),
window.innerWidth - MARGIN
);
setPosition({ top: placement === "top" ? rect.top - MARGIN : rect.bottom + MARGIN, left, placement });
}, []);
function show() {
reposition();
setOpen(true);
}
function hide() {
setOpen(false);
}
React.useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
return () => {
document.removeEventListener("keydown", handleKey);
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
};
}, [open, reposition]);
return (
<>
<button
ref={triggerRef}
type="button"
aria-describedby={open ? tooltipId : undefined}
aria-expanded={open}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="absolute right-0 top-0 inline-flex items-center gap-1 rounded-full border border-emerald-600/30 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 transition-colors hover:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-400 dark:hover:bg-emerald-950/70"
>
<ShieldCheck className="size-3" aria-hidden />
Trusted
</button>
{mounted &&
createPortal(
<AnimatePresence>
{open && position && (
<div
ref={tooltipRef}
style={{
position: "fixed",
top: position.top,
left: position.left,
width: TOOLTIP_WIDTH,
transform:
position.placement === "top"
? `translate(-100%, -100%)`
: `translate(-100%, 0)`,
}}
className="z-50"
>
<motion.div
id={tooltipId}
role="tooltip"
initial={{ opacity: 0, y: position.placement === "top" ? 4 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: position.placement === "top" ? 4 : -4 }}
transition={{ duration: 0.12 }}
className="rounded-xl border bg-popover p-3 text-left shadow-md"
>
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<ShieldCheck className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
Trusted
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
<span className="font-medium text-foreground/80">{domain}</span> {reason}
</p>
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-1.5 inline-block text-xs font-medium text-primary hover:underline"
>
Learn more
</a>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)}
</>
);
}
/** True only once the client has rendered — lets a portal target `document.body` without an SSR mismatch. */
function useMounted() {
return React.useSyncExternalStore(
() => () => {},
() => true,
() => false
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Source Trust Card
## Summary
A paginated card for a single cited source, showing its favicon, title, and
snippet, plus an optional "Trusted" badge that reveals why the source is
credible on hover. A row of domain pills below lets the reader jump
straight to any source in the set.
## When to use
- An AI answer cites a small set of web sources (roughly 2–6) and the
reader may want to inspect each one before trusting the answer.
- At least one source benefits from an explicit credibility signal — an
official vendor site, a government domain, a well-known registrar or
standards body — where "why should I trust this" isn't obvious from the
domain alone.
- The reader is likely to check sources one at a time rather than scan
them all at once (contrast with a list or stack view).
## When not to use
- A single source — show it inline or as a plain link; pagination chrome
for one item is dead weight.
- More than ~6–8 sources — pagination through a long set is tedious; use
`Sources Stack` (an expandable list) instead.
- Every source is equally low-stakes and none needs a trust explanation —
a plain source list is lighter and just as useful.
- Inline, mid-sentence citation markers — use `Inline Citation` for that;
this pattern is a standalone card, not a footnote.
## Anatomy
- Header: previous/next controls, a "current/total" counter, and a
"N sources" label.
- Source body (clickable, opens the source): favicon, domain, title,
short description/snippet.
- Trust badge (optional, only when a source is verified): a small shield
icon + "Trusted" pill pinned above the domain; hovering or focusing it
reveals a tooltip with one sentence explaining why, and a "Learn more"
link.
- Domain pill row (only when there's more than one source): one pill per
source for direct navigation; the active pill is visually distinct.
## Behavior
- Previous/next buttons cycle through sources with a short slide + fade
transition in the direction of travel; wrap around at the ends.
- Clicking a domain pill jumps directly to that source, sliding in the
correct direction (forward if later in the set, backward if earlier).
- The whole source body (favicon, title, description) is a single link
that opens the source in a new tab; the trust badge sits outside that
link so it can be hovered or focused independently.
- The trust badge only renders when the current source has a trust
reason — most sources in a set will not have one, and that's expected.
It stays collapsed to the "Trusted" pill until hovered or focused, so it
never pushes the card's height around.
- The tooltip opens on hover or keyboard focus, closes on mouse leave,
blur, or Escape, and flips above or below the badge depending on
available viewport space.
- Favicon falls back to a generic globe icon if the image is missing or
fails to load.
## Content guidelines
- Title: the source's actual page title, truncated rather than rewritten.
- Description: a one- to two-line snippet in the source's own voice, not
editorial commentary.
- Trust reason: one short sentence stating the concrete basis for trust
(e.g. "is trusted for official domain registration... from a U.S.
provider"), not a vague "this is a good source."
- Domain pills show the bare domain (`cloudflare.com`), not the full title.
## Accessibility
- Previous/next buttons need accessible names ("Previous source" / "Next
source"), not just chevron icons.
- The counter and "N sources" text give screen reader users the set size
and position without relying on the visual pagination alone.
- Domain pills use `aria-current` on the active source so assistive tech
can tell which one is showing.
- The trust badge is a real `button`, reachable by keyboard: focusing it
opens the tooltip the same way hovering does, and the tooltip is linked
back to the badge with `aria-describedby` so screen readers announce it.
- Respect `prefers-reduced-motion`: reduce or remove the slide transition
between sources and the tooltip's fade-in.
## Related patterns
- `Sources Stack` — better for larger sets browsed as a single list
rather than one at a time.
- `Inline Citation` — for footnote-style markers inside response text,
rather than a standalone card.