{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"citations","type":"registry:component","title":"Citations","description":"Inline numbered citation chips that open a source card with a spring on hover or focus, plus a sources list that highlights the cited item.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ai/citations.tsx","type":"registry:component","target":"components/ai/citations.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationSource {\n  title: string;\n  url: string;\n  /** Short excerpt that supports the claim. */\n  snippet?: string;\n  /** Overrides the domain parsed from `url`. */\n  domain?: string;\n  /** e.g. \"Mar 2026\" or \"Docs\". */\n  meta?: string;\n}\n\ninterface Ctx {\n  sources: CitationSource[];\n  active: number | null;\n  setActive: (n: number | null) => void;\n  idBase: string;\n}\n\nconst CitationsContext = React.createContext<Ctx | null>(null);\n\nfunction useCitations() {\n  const ctx = React.useContext(CitationsContext);\n  if (!ctx) throw new Error(\"Citation components must be rendered inside <Citations>.\");\n  return ctx;\n}\n\nfunction domainOf(s: CitationSource) {\n  if (s.domain) return s.domain;\n  try {\n    return new URL(s.url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return s.url;\n  }\n}\n\nfunction SourceMark({ source, className }: { source: CitationSource; className?: string }) {\n  const d = domainOf(source);\n  return (\n    <span aria-hidden className={cn(\"grid size-5 shrink-0 place-items-center rounded-[6px] bg-surface text-[10px] font-semibold uppercase text-ink-muted ring-1 ring-border\", className)}>\n      {d.charAt(0)}\n    </span>\n  );\n}\n\nexport interface CitationsProps {\n  sources: CitationSource[];\n  children: React.ReactNode;\n  className?: string;\n}\n\n/** Provides numbered sources to inline <Citation> chips and the <CitationSources> list. */\nexport function Citations({ sources, children, className }: CitationsProps) {\n  const [active, setActive] = React.useState<number | null>(null);\n  const idBase = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const value = React.useMemo(() => ({ sources, active, setActive, idBase }), [sources, active, idBase]);\n  return (\n    <CitationsContext.Provider value={value}>\n      <div className={className}>{children}</div>\n    </CitationsContext.Provider>\n  );\n}\n\n/** Inline numbered chip; hover or focus opens a source card. `n` is 1-based. */\nexport function Citation({ n }: { n: number }) {\n  const { sources, active, setActive, idBase } = useCitations();\n  const reduce = useReducedMotion();\n  const [open, setOpen] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const source = sources[n - 1];\n  const cardId = `${idBase}-card-${n}`;\n\n  React.useEffect(() => () => {\n    if (timer.current) clearTimeout(timer.current);\n  }, []);\n\n  if (!source) return null;\n\n  const show = () => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() => {\n      setOpen(true);\n      setActive(n);\n    }, 90);\n  };\n  const hide = () => {\n    if (timer.current) clearTimeout(timer.current);\n    setOpen(false);\n    if (active === n) setActive(null);\n  };\n\n  return (\n    <span className=\"relative inline-block align-baseline\" onPointerEnter={show} onPointerLeave={hide}>\n      <a\n        href={`#${idBase}-src-${n}`}\n        aria-describedby={open ? cardId : undefined}\n        aria-label={`Source ${n}: ${source.title}`}\n        onFocus={show}\n        onBlur={hide}\n        onKeyDown={(e) => e.key === \"Escape\" && hide()}\n        className={cn(\n          \"mx-0.5 inline-grid h-[18px] min-w-[18px] -translate-y-px place-items-center rounded-full px-1 align-middle font-mono text-[10.5px] font-semibold no-underline outline-none transition-colors duration-150\",\n          \"focus-visible:shadow-[var(--focus-ring)]\",\n          open || active === n ? \"bg-volt text-on-volt\" : \"bg-surface text-ink-muted ring-1 ring-border hover:text-ink\",\n        )}\n      >\n        {n}\n      </a>\n      <AnimatePresence>\n        {open && (\n          <motion.span\n            id={cardId}\n            role=\"tooltip\"\n            initial={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.95, x: \"-50%\", filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, x: \"-50%\", filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, y: 4, scale: 0.97, x: \"-50%\", filter: \"blur(4px)\", transition: { duration: 0.12 } }}\n            transition={{ type: \"spring\", stiffness: 420, damping: 30 }}\n            className=\"absolute bottom-[calc(100%+8px)] left-1/2 z-[60] block w-[min(300px,80vw)] origin-bottom rounded-md border border-border bg-surface-raised p-3 text-left shadow-lg\"\n          >\n            <span className=\"flex items-center gap-2\">\n              <SourceMark source={source} />\n              <span className=\"truncate text-[12px] text-ink-subtle\">{domainOf(source)}</span>\n              {source.meta && <span className=\"ml-auto shrink-0 text-[11px] text-ink-subtle\">{source.meta}</span>}\n            </span>\n            <span className=\"mt-2 block text-[13.5px] font-semibold leading-5 text-ink\">{source.title}</span>\n            {source.snippet && <span className=\"mt-1 line-clamp-3 block text-[12.5px] leading-[19px] text-ink-muted\">{source.snippet}</span>}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Footer list of all sources; the one under a hovered chip is highlighted. */\nexport function CitationSources({ title = \"Sources\", className }: { title?: string; className?: string }) {\n  const { sources, active, setActive, idBase } = useCitations();\n  return (\n    <div className={cn(\"mt-4 border-t border-border pt-3\", className)}>\n      <h4 className=\"mb-2 text-[11px] font-semibold uppercase tracking-[0.6px] text-ink-subtle\">{title}</h4>\n      <ol className=\"grid gap-1.5 sm:grid-cols-2\">\n        {sources.map((s, i) => {\n          const n = i + 1;\n          const on = active === n;\n          return (\n            <li key={s.url + i} id={`${idBase}-src-${n}`} className=\"scroll-mt-24\">\n              <a\n                href={s.url}\n                target=\"_blank\"\n                rel=\"noreferrer noopener\"\n                onPointerEnter={() => setActive(n)}\n                onPointerLeave={() => setActive(null)}\n                onFocus={() => setActive(n)}\n                onBlur={() => setActive(null)}\n                className={cn(\n                  \"group/src relative flex items-center gap-2.5 rounded-sm border px-2.5 py-2 outline-none transition-colors duration-200 focus-visible:shadow-[var(--focus-ring)]\",\n                  on ? \"border-volt-ink/50 bg-volt-soft\" : \"border-border bg-surface hover:border-border-strong\",\n                )}\n              >\n                <span className={cn(\"grid size-5 shrink-0 place-items-center rounded-full font-mono text-[10.5px] font-semibold transition-colors\", on ? \"bg-volt text-on-volt\" : \"bg-surface-sunken text-ink-muted\")}>\n                  {n}\n                </span>\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate text-[13px] font-medium text-ink\">{s.title}</span>\n                  <span className=\"block truncate text-[11.5px] text-ink-subtle\">{domainOf(s)}</span>\n                </span>\n                <ArrowUpRight aria-hidden className=\"size-3.5 shrink-0 text-ink-subtle transition-transform duration-300 ease-[var(--ease-spring)] group-hover/src:-translate-y-0.5 group-hover/src:translate-x-0.5\" />\n                <span className=\"sr-only\">(opens in a new tab)</span>\n              </a>\n            </li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n\n/** Renders a string with `[n]` markers replaced by <Citation> chips. Must sit inside <Citations>. */\nexport function CitedText({ text, className }: { text: string; className?: string }) {\n  const parts = text.split(/(\\[\\d+\\])/g);\n  return (\n    <p className={cn(\"text-[14.5px] leading-[24px] text-ink\", className)}>\n      {parts.map((p, i) => {\n        const m = p.match(/^\\[(\\d+)\\]$/);\n        return m ? <Citation key={i} n={Number(m[1])} /> : <React.Fragment key={i}>{p}</React.Fragment>;\n      })}\n    </p>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["ai","ai","citations","sources","references","popover","rag"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/citations"}}