# AI Source Citations — React AI Component

> An answer with numbered citation markers; hovering or focusing one pops a source card and lights the matching source in the strip below.

- Category: [AI Elements](https://pixelpapa.com/components/ai.md)
- Tags: ai, citations, sources, popover, answer
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/ai/ai-source-citations

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/ai-source-citations.json"
```

The file is written to `components/pixelpapa/ai-source-citations.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ExternalLink } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";

export type CitationSource = { title: string; site: string; snippet: string; date: string; color?: string };
/** Plain strings are text; numbers are 1-based indexes into `sources`. */
export type AnswerPart = string | number;

type SourceCitationsProps = { parts: AnswerPart[]; sources: CitationSource[]; className?: string };

const TINTS = ["var(--chart-1)", "var(--chart-5)", "var(--chart-3)", "var(--chart-4)", "var(--chart-2)"];

function Favicon({ source, index, className }: { source: CitationSource; index: number; className?: string }) {
  const color = source.color ?? TINTS[index % TINTS.length];
  return (
    <span
      aria-hidden
      className={cn("flex size-5 shrink-0 items-center justify-center rounded-md text-[10px] font-bold uppercase", className)}
      style={{ background: `color-mix(in oklch, ${color} 18%, transparent)`, color }}
    >
      {source.site[0]}
    </span>
  );
}

export function SourceCitations({ parts, sources, className }: SourceCitationsProps) {
  const reduce = useReducedMotion();
  // `at` is the position in `parts`, so the same source cited twice opens one card at a time.
  const [open, setOpen] = useState<{ at: number; n: number } | null>(null);

  return (
    <div className={cn("w-full", className)}>
      <p className="text-[15px] leading-7 text-foreground">
        {parts.map((part, i) => {
          if (typeof part === "string") return <span key={i}>{part}</span>;
          const source = sources[part - 1];
          const on = open?.at === i;
          return (
            <span key={i} className="relative inline-block align-baseline" onPointerEnter={() => setOpen({ at: i, n: part })} onPointerLeave={() => setOpen(null)}>
              <button
                type="button"
                aria-label={`Source ${part}: ${source.title}`}
                aria-expanded={on}
                onFocus={() => setOpen({ at: i, n: part })}
                onBlur={() => setOpen(null)}
                onKeyDown={(e) => e.key === "Escape" && setOpen(null)}
                className={cn(
                  "mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-0.5 items-center justify-center rounded-full px-1 text-[10px] font-semibold outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring",
                  on || open?.n === part ? "scale-110 bg-primary text-primary-foreground" : "bg-primary/10 text-primary",
                )}
              >
                {part}
              </button>
              <AnimatePresence>
                {on && (
                  <motion.span
                    role="tooltip"
                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.1 } }}
                    transition={{ type: "spring", stiffness: 500, damping: 32 }}
                    className="absolute bottom-full left-1/2 z-20 -ml-32 block w-64 origin-bottom pb-2"
                  >
                    <span className="block rounded-xl border border-border bg-popover p-3 text-left shadow-xl">
                      <span className="flex items-center gap-2 text-xs text-muted-foreground">
                        <Favicon source={source} index={part - 1} />
                        <span className="truncate">{source.site}</span>
                        <span className="ml-auto shrink-0">{source.date}</span>
                      </span>
                      <span className="mt-2 block text-sm leading-snug font-medium text-popover-foreground">{source.title}</span>
                      <span className="mt-1 line-clamp-3 block text-xs leading-relaxed text-muted-foreground">{source.snippet}</span>
                    </span>
                  </motion.span>
                )}
              </AnimatePresence>
            </span>
          );
        })}
      </p>

      <p className="mt-5 mb-2 text-xs font-medium text-muted-foreground">{sources.length} sources</p>
      <ol className="grid gap-2 sm:grid-cols-3">
        {sources.map((s, i) => {
          const lit = open?.n === i + 1;
          return (
            <motion.li
              key={s.title}
              initial={reduce ? false : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ delay: 0.15 + i * 0.07, duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
            >
              <a
                href="#"
                onClick={(e) => e.preventDefault()}
                onPointerEnter={() => setOpen({ at: -1, n: i + 1 })}
                onPointerLeave={() => setOpen(null)}
                className={cn(
                  "group block h-full rounded-xl border p-3 outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring",
                  lit ? "-translate-y-0.5 border-primary/60 bg-primary/5 shadow-md shadow-primary/10" : "border-border bg-card",
                )}
              >
                <span className="flex items-center gap-2 text-xs text-muted-foreground">
                  <span className="flex size-4 items-center justify-center rounded-full bg-muted text-[9px] font-semibold text-foreground">{i + 1}</span>
                  <Favicon source={s} index={i} className="size-4 rounded-[5px] text-[9px]" />
                  <span className="truncate">{s.site}</span>
                  <ExternalLink className="ml-auto size-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
                </span>
                <span className="mt-1.5 line-clamp-2 block text-xs leading-snug font-medium text-card-foreground">{s.title}</span>
              </a>
            </motion.li>
          );
        })}
      </ol>
    </div>
  );
}

const sources: CitationSource[] = [
  { title: "Early churn in seat-based SaaS: a 2026 benchmark", site: "Ledgerline", date: "Mar 2026", snippet: "Across 412 companies, median logo churn in the first 60 days was 6.1%, falling to 2.3% once a second integration was connected." },
  { title: "Why the second integration predicts retention", site: "Field Notes", date: "Aug 2025", snippet: "Teams that wire a product into two existing tools have rebuilt a workflow around it, which raises switching cost far more than seat count does." },
  { title: "Onboarding interviews: asking about the first day", site: "Practice Lab", date: "Jan 2026", snippet: "Ask what people were trying to finish on the day they signed up. Reasons given for leaving skew toward price even when price was not the cause." },
];

const parts: AnswerPart[] = [
  "Losing accounts in the first two months is common: the median for seat-based tools is about 6%",
  1,
  ". The strongest single predictor of staying is connecting a second integration",
  2,
  ", which fell from 6.1% to 2.3% churn in the same study",
  1,
  ". When you interview the accounts that left, start with what they hoped to finish on day one rather than why they cancelled",
  3,
  ".",
];

export default function AiSourceCitationsDemo() {
  return (
    <div className="w-full max-w-xl rounded-3xl border border-border bg-card p-6 pt-5 shadow-sm">
      <SourceCitations parts={parts} sources={sources} />
    </div>
  );
}
```

## AI prompt

```text
Add the "AI Source Citations" component from PixelPapa UI to this project.

What it is: An answer with numbered citation markers; hovering or focusing one pops a source card and lights the matching source in the strip below.
Category: AI Elements. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/ai-source-citations.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/ai-source-citations.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/ai-source-citations" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More ai elements

- [AI Chat Thread](https://pixelpapa.com/components/ai/ai-chat-thread.md) (Pro): A chat thread where user bubbles spring in from the right and the assistant reply streams in chunks behind a blinking caret, auto-scrolling as it grows.
- [AI Code Block With Copy](https://pixelpapa.com/components/ai/ai-code-block-copy.md) (Pro): A code answer with sliding language tabs, line numbers, highlighted lines that wipe in, and a copy button that morphs into a confirmed tick.
- [AI Model Compare](https://pixelpapa.com/components/ai/ai-model-compare.md): A blind side-by-side: two answers stream at their own speeds with live token stats, then your vote crowns a winner and reveals both model names.
- [AI Prompt Input](https://pixelpapa.com/components/ai/ai-prompt-input.md) (Pro): A chat composer with an auto-growing textarea, removable attachment chips, a model picker popover and a send button that morphs into stop while generating.
- [AI Streaming Markdown](https://pixelpapa.com/components/ai/ai-streaming-markdown.md): Renders a markdown answer as it streams: each word fades up out of a blur, with headings, lists, bold and inline code formatted as they arrive.
- [AI Suggestion Chips](https://pixelpapa.com/components/ai/ai-suggestion-chips.md) (Pro): Prompt suggestions grouped by intent; chips cascade in when you switch group, and picking one types its prompt into the input.
