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