{
  "name": "ai-citations",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @kokonutui\n * @description: AI Inline Citations\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { ArrowUpRight, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationSource {\n  id: string;\n  title: string;\n  url: string;\n  /** Short site name for the chip, such as `MDN`. Defaults to the host. */\n  site?: string;\n  /** The passage that backs the claim, shown in the preview. */\n  snippet?: string;\n}\n\nexport interface AnswerPart {\n  text: string;\n  /** Ids of the sources that back this text. A chip follows it. */\n  cite?: string[];\n}\n\ninterface AICitationsProps {\n  /** The answer, split where citations go. Demo content when omitted. */\n  parts?: AnswerPart[];\n  sources?: CitationSource[];\n  className?: string;\n}\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst CARD_WIDTH = 288;\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 90;\n/** After a preview closes, the next one within this window opens at once. */\nconst WARM_MS = 400;\n\nconst DEMO_SOURCES: CitationSource[] = [\n  {\n    id: \"emil\",\n    title: \"Great animations\",\n    url: \"https://emilkowal.ski/ui/great-animations\",\n    site: \"emilkowal.ski\",\n    snippet:\n      \"UI motion should be fast, mostly under 300ms, with an ease-out curve so movement starts right away. Popovers scale from their trigger.\",\n  },\n  {\n    id: \"webdev\",\n    title: \"Animations and performance\",\n    url: \"https://web.dev/articles/animations-and-performance\",\n    site: \"web.dev\",\n    snippet:\n      \"Stick to transform and opacity where you can. Changing them skips layout and paint, so the compositor can run the animation.\",\n  },\n  {\n    id: \"mdn-perf\",\n    title: \"CSS and JavaScript animation performance\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/CSS_JavaScript_animation_performance\",\n    site: \"MDN\",\n    snippet:\n      \"Properties that trigger layout are expensive to animate. Transforms and opacity can be handed to the GPU.\",\n  },\n  {\n    id: \"mdn-reduce\",\n    title: \"prefers-reduced-motion\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion\",\n    site: \"MDN\",\n    snippet:\n      \"Detects whether the user asked the system to minimize non-essential motion.\",\n  },\n];\n\nconst DEMO_PARTS: AnswerPart[] = [\n  { text: \"Keep the dropdown quick. \" },\n  {\n    text: \"Most UI motion should finish in under 300ms, and an ease-out curve feels faster than ease-in at the same duration.\",\n    cite: [\"emil\"],\n  },\n  { text: \" \" },\n  {\n    text: \"Animate only transform and opacity, which the browser can run without layout or paint.\",\n    cite: [\"webdev\", \"mdn-perf\"],\n  },\n  { text: \" \" },\n  {\n    text: \"When someone asks for reduced motion, swap the movement for a plain fade.\",\n    cite: [\"mdn-reduce\"],\n  },\n  { text: \" Finally, \" },\n  {\n    text: \"scale the menu from its trigger rather than its center\",\n    cite: [\"emil\"],\n  },\n  { text: \", so it feels attached to the button that opened it.\" },\n];\n\nconst hostOf = (url: string) => {\n  try {\n    return new URL(url).hostname.replace(\"www.\", \"\");\n  } catch {\n    return url;\n  }\n};\nconst siteOf = (source: CitationSource) => source.site ?? hostOf(source.url);\n\nfunction Monogram({\n  source,\n  className,\n}: {\n  source: CitationSource;\n  className?: string;\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-zinc-200 font-semibold text-[9px] text-zinc-600 uppercase dark:bg-zinc-700 dark:text-zinc-200\",\n        className\n      )}\n    >\n      {siteOf(source).charAt(0)}\n    </span>\n  );\n}\n\ninterface Preview {\n  /** Index of the cited part the chip belongs to. */\n  part: number;\n  x: number;\n  y: number;\n}\n\n/**\n * Opens a preview after a short hover, and lets it glide from chip to chip\n * without re-entering while the user moves along a sentence.\n */\nfunction usePreview() {\n  const [preview, setPreview] = useState<Preview | null>(null);\n  const [warm, setWarm] = useState(false);\n  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const lastClosed = useRef(0);\n\n  const clear = useCallback(() => {\n    if (openTimer.current) {\n      clearTimeout(openTimer.current);\n    }\n    if (closeTimer.current) {\n      clearTimeout(closeTimer.current);\n    }\n  }, []);\n\n  const show = useCallback(\n    (next: Preview, immediate = false) => {\n      clear();\n      const isWarm =\n        preview !== null || Date.now() - lastClosed.current < WARM_MS;\n      if (immediate || isWarm) {\n        setWarm(preview !== null);\n        setPreview(next);\n        return;\n      }\n      openTimer.current = setTimeout(() => {\n        setWarm(false);\n        setPreview(next);\n      }, OPEN_DELAY_MS);\n    },\n    [preview, clear]\n  );\n\n  const hide = useCallback(\n    (immediate = false) => {\n      clear();\n      const close = () => {\n        lastClosed.current = Date.now();\n        setPreview(null);\n      };\n      if (immediate) {\n        close();\n        return;\n      }\n      closeTimer.current = setTimeout(close, CLOSE_DELAY_MS);\n    },\n    [clear]\n  );\n\n  useEffect(() => clear, [clear]);\n\n  return { preview, warm, show, hide };\n}\n\nexport default function AICitations({\n  parts = DEMO_PARTS,\n  sources = DEMO_SOURCES,\n  className,\n}: AICitationsProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const { preview, warm, show, hide } = usePreview();\n  const [hoveredSource, setHoveredSource] = useState<string | null>(null);\n  const [listOpen, setListOpen] = useState(false);\n  const previewId = useId();\n  const listId = useId();\n\n  const byId = new Map(sources.map((source) => [source.id, source]));\n  // Sources numbered in the order the answer first cites them.\n  const order: string[] = [];\n  for (const part of parts) {\n    for (const id of part.cite ?? []) {\n      if (!order.includes(id) && byId.has(id)) {\n        order.push(id);\n      }\n    }\n  }\n  const cited = order\n    .map((id) => byId.get(id))\n    .filter((source): source is CitationSource => Boolean(source));\n\n  const place = (index: number, chip: HTMLElement): Preview => {\n    const root = rootRef.current?.getBoundingClientRect();\n    const rect = chip.getBoundingClientRect();\n    const width = Math.min(CARD_WIDTH, root?.width ?? CARD_WIDTH);\n    const left = rect.left - (root?.left ?? 0) + rect.width / 2 - width / 2;\n    const max = (root?.width ?? width) - width;\n    return {\n      part: index,\n      x: Math.min(Math.max(0, left), Math.max(0, max)),\n      y: rect.top - (root?.top ?? 0) - 8,\n    };\n  };\n\n  const active = preview ? parts[preview.part] : null;\n  const activeSources = (active?.cite ?? [])\n    .map((id) => byId.get(id))\n    .filter((source): source is CitationSource => Boolean(source));\n\n  // Escape closes a preview opened by keyboard or tap.\n  useEffect(() => {\n    if (!preview) {\n      return;\n    }\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        hide(true);\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [preview, hide]);\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"relative w-full max-w-lg\", className)} ref={rootRef}>\n        <p className=\"text-[15px] text-zinc-800 leading-7 dark:text-zinc-200\">\n          {parts.map((part, index) => {\n            const partSources = (part.cite ?? [])\n              .map((id) => byId.get(id))\n              .filter((source): source is CitationSource => Boolean(source));\n            if (partSources.length === 0) {\n              // biome-ignore lint/suspicious/noArrayIndexKey: parts are an ordered, static answer\n              return <span key={index}>{part.text}</span>;\n            }\n            const first = partSources[0];\n            const isActive = preview?.part === index;\n            const isLinked = partSources.some(\n              (source) => source.id === hoveredSource\n            );\n            return (\n              // biome-ignore lint/suspicious/noArrayIndexKey: parts are an ordered, static answer\n              <span key={index}>\n                {/* The claim a chip supports lights up while it's previewed. */}\n                <span\n                  className={cn(\n                    \"rounded-[3px] decoration-1 underline-offset-4 transition-[background-color,text-decoration-color] duration-150\",\n                    isActive || isLinked\n                      ? \"bg-sky-100/80 underline decoration-sky-400/70 dark:bg-sky-400/15 dark:decoration-sky-400/50\"\n                      : \"bg-transparent underline decoration-transparent\"\n                  )}\n                >\n                  {part.text}\n                </span>{\" \"}\n                <button\n                  aria-describedby={isActive ? previewId : undefined}\n                  aria-label={`Sources: ${partSources.map(siteOf).join(\", \")}`}\n                  className={cn(\n                    \"inline-flex h-5 translate-y-[-1px] items-center gap-1 rounded-full py-0 pr-1.5 pl-0.5 align-middle font-medium text-[11px] outline-hidden transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-sky-500/50\",\n                    isActive || isLinked\n                      ? \"bg-zinc-900 text-white dark:bg-white dark:text-zinc-900\"\n                      : \"bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-white/10 dark:text-zinc-300 dark:hover:bg-white/15\"\n                  )}\n                  onBlur={() => hide(true)}\n                  // Focus already opened it for mouse and keyboard; a tap\n                  // on touch opens it here. Blur or Escape closes it.\n                  onClick={(event) =>\n                    show(place(index, event.currentTarget), true)\n                  }\n                  onFocus={(event) =>\n                    show(place(index, event.currentTarget), true)\n                  }\n                  onPointerEnter={(event) => {\n                    if (event.pointerType === \"mouse\") {\n                      show(place(index, event.currentTarget));\n                    }\n                  }}\n                  onPointerLeave={(event) => {\n                    if (event.pointerType === \"mouse\") {\n                      hide();\n                    }\n                  }}\n                  type=\"button\"\n                >\n                  <Monogram className=\"size-4 rounded-full\" source={first} />\n                  {siteOf(first)}\n                  {partSources.length > 1 && (\n                    <span className=\"opacity-60\">\n                      +{partSources.length - 1}\n                    </span>\n                  )}\n                </button>\n              </span>\n            );\n          })}\n        </p>\n\n        {/* One preview for every chip. It glides between chips once open,\n            and fades in fresh after a pause. */}\n        <AnimatePresence>\n          {preview && activeSources.length > 0 && (\n            <motion.div\n              animate={{\n                opacity: 1,\n                scale: 1,\n                x: preview.x,\n                y: preview.y,\n              }}\n              className=\"pointer-events-none absolute top-0 left-0 z-10\"\n              exit={{ opacity: 0, scale: 0.97 }}\n              id={previewId}\n              initial={{ opacity: 0, scale: 0.97, x: preview.x, y: preview.y }}\n              role=\"tooltip\"\n              style={{\n                // The card hangs above this box, so its top edge is the\n                // card's bottom: grow from there, toward the chip.\n                transformOrigin: \"50% 0%\",\n                width: Math.min(\n                  CARD_WIDTH,\n                  rootRef.current?.offsetWidth ?? CARD_WIDTH\n                ),\n              }}\n              transition={{\n                opacity: { duration: 0.14, ease: EASE_OUT },\n                scale: { duration: 0.14, ease: EASE_OUT },\n                x: warm ? { duration: 0.22, ease: EASE_OUT } : { duration: 0 },\n                y: warm ? { duration: 0.22, ease: EASE_OUT } : { duration: 0 },\n              }}\n            >\n              <div className=\"origin-bottom -translate-y-full rounded-xl bg-white p-1 shadow-[0_8px_24px_-8px_rgb(0_0_0/0.2)] ring-1 ring-black/10 dark:bg-zinc-900 dark:ring-white/10\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.div\n                    animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                    exit={{ opacity: 0, filter: \"blur(2px)\" }}\n                    initial={{ opacity: 0, filter: \"blur(2px)\" }}\n                    key={preview.part}\n                    transition={{ duration: 0.16, ease: EASE_OUT }}\n                  >\n                    {activeSources.map((source) => (\n                      <div className=\"rounded-lg p-2.5\" key={source.id}>\n                        <div className=\"flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400\">\n                          <Monogram source={source} />\n                          <span className=\"truncate\">{hostOf(source.url)}</span>\n                          <span className=\"ml-auto shrink-0 tabular-nums\">\n                            {order.indexOf(source.id) + 1}\n                          </span>\n                        </div>\n                        <p className=\"mt-1.5 font-medium text-sm text-zinc-900 leading-snug dark:text-zinc-100\">\n                          {source.title}\n                        </p>\n                        {source.snippet && (\n                          <p className=\"mt-1 line-clamp-3 text-xs text-zinc-500 leading-relaxed dark:text-zinc-400\">\n                            {source.snippet}\n                          </p>\n                        )}\n                      </div>\n                    ))}\n                  </motion.div>\n                </AnimatePresence>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {cited.length > 0 && (\n          <div className=\"mt-4\">\n            <button\n              aria-controls={listId}\n              aria-expanded={listOpen}\n              className=\"group/sources flex items-center gap-2 rounded-full py-1 pr-2.5 pl-1 text-xs text-zinc-600 outline-hidden transition-colors duration-150 hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-sky-500/50 dark:text-zinc-300 dark:hover:bg-white/10\"\n              onClick={() => setListOpen((open) => !open)}\n              type=\"button\"\n            >\n              <span className=\"flex -space-x-1\">\n                {cited.slice(0, 4).map((source) => (\n                  <Monogram\n                    className=\"size-5 rounded-full ring-2 ring-white dark:ring-zinc-950\"\n                    key={source.id}\n                    source={source}\n                  />\n                ))}\n              </span>\n              {cited.length} sources\n              <ChevronDown\n                aria-hidden=\"true\"\n                className={cn(\n                  \"size-3.5 text-zinc-400 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                  listOpen && \"rotate-180\"\n                )}\n              />\n            </button>\n            <AnimatePresence initial={false}>\n              {listOpen && (\n                <motion.ol\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  className=\"overflow-hidden\"\n                  exit={{ height: 0, opacity: 0 }}\n                  id={listId}\n                  initial={{ height: 0, opacity: 0 }}\n                  transition={{ duration: 0.22, ease: EASE_OUT }}\n                >\n                  {cited.map((source, index) => (\n                    <li className=\"pt-1 first:pt-2\" key={source.id}>\n                      <a\n                        className=\"group/source flex items-center gap-2.5 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-150 hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-sky-500/50 dark:hover:bg-white/5\"\n                        href={source.url}\n                        onBlur={() => setHoveredSource(null)}\n                        onFocus={() => setHoveredSource(source.id)}\n                        onPointerEnter={() => setHoveredSource(source.id)}\n                        onPointerLeave={() => setHoveredSource(null)}\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\"\n                      >\n                        <span className=\"w-3 shrink-0 text-right text-xs text-zinc-400 tabular-nums\">\n                          {index + 1}\n                        </span>\n                        <Monogram source={source} />\n                        <span className=\"min-w-0 flex-1 truncate text-sm text-zinc-800 dark:text-zinc-200\">\n                          {source.title}\n                        </span>\n                        <span className=\"hidden shrink-0 text-xs text-zinc-400 sm:inline dark:text-zinc-500\">\n                          {hostOf(source.url)}\n                        </span>\n                        <ArrowUpRight\n                          aria-hidden=\"true\"\n                          className=\"size-3.5 shrink-0 text-zinc-300 transition-colors duration-150 group-hover/source:text-zinc-600 dark:text-zinc-600 dark:group-hover/source:text-zinc-300\"\n                        />\n                      </a>\n                    </li>\n                  ))}\n                </motion.ol>\n              )}\n            </AnimatePresence>\n          </div>\n        )}\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "path": "/components/kokonutui/ai-citations.tsx",
      "target": "components/kokonutui/ai-citations.tsx"
    }
  ]
}