{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Favicon stack that fans out on hover and expands into a source list, with each favicon morphing into its row.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ChevronRight, Globe } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useId, useState } from \"react\";\n\nconst SPRING_DEFAULT = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** Favicons shown in the collapsed stack before the \"+n\" chip. */\nconst STACK_LIMIT = 3;\n/** Overlap of the collapsed stack, and how far it fans on hover. */\nconst STACK_OVERLAP_PX = 8;\nconst FAN_GAP_PX = 3;\nconst ROW_STAGGER = 0.035;\n\nexport type AISource = {\n  /**\n   * The source's mark — a real isotype or an `<img>`, never a letter.\n   *\n   * A node rather than a URL so a brand's own SVG can be passed straight in.\n   * When omitted the component draws a neutral globe: an honest \"unknown host\"\n   * icon beats a fabricated one-letter badge pretending to be a logo.\n   */\n  favicon?: ReactNode;\n  id: string;\n  /** Short excerpt or description. */\n  snippet?: string;\n  title: string;\n  url: string;\n};\n\nexport type AISourcesProps = {\n  className?: string;\n  /** Start expanded. */\n  defaultOpen?: boolean;\n  /** Label before the stack, e.g. \"Sources\". */\n  label?: string;\n  sources: AISource[];\n};\n\nconst hostOf = (url: string): string => {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n};\n\nconst Favicon = ({ size, source }: { size: number; source: AISource }) => (\n  <span\n    className=\"flex items-center justify-center overflow-hidden rounded-full border border-border bg-background text-muted-foreground\"\n    style={{ height: size, width: size }}\n  >\n    {source.favicon ? (\n      // Sized here so any mark fits: an `<img>` from a logo service, or a repo\n      // isotype SVG, which ships as `fill=\"none\"` and expects the host to pick\n      // the colour — the same contract the logo-cloud blocks rely on.\n      <span className=\"flex size-full items-center justify-center *:size-full *:fill-foreground *:object-cover\">\n        {source.favicon}\n      </span>\n    ) : (\n      <Globe aria-hidden=\"true\" size={size * 0.6} />\n    )}\n  </span>\n);\n\n/**\n * The sources behind an answer.\n *\n * Collapsed to a stack of overlapping favicons, because provenance should be\n * available rather than loud. Hovering fans the stack apart as a hint that it is\n * a set and not a single badge; opening it moves each favicon into its own row —\n * a shared layout id, so the icon the eye was tracking is the icon that lands.\n */\nconst AISources = ({\n  className,\n  defaultOpen = false,\n  label = \"Sources\",\n  sources,\n}: AISourcesProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const [isHovered, setIsHovered] = useState(false);\n  const layoutPrefix = useId();\n\n  const stacked = sources.slice(0, STACK_LIMIT);\n  const overflow = sources.length - stacked.length;\n  const fanned = isHovered && !isOpen && !shouldReduceMotion;\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <button\n        aria-expanded={isOpen}\n        className=\"flex cursor-pointer items-center gap-2 rounded-lg py-1 text-muted-foreground text-sm transition-colors hover:text-foreground\"\n        onBlur={() => setIsHovered(false)}\n        onClick={() => setIsOpen((current) => !current)}\n        onFocus={() => setIsHovered(true)}\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n        type=\"button\"\n      >\n        <span className=\"flex items-center\">\n          {stacked.map((source, index) => (\n            <motion.span\n              animate={{\n                marginLeft:\n                  index === 0 ? 0 : fanned ? FAN_GAP_PX : -STACK_OVERLAP_PX,\n              }}\n              className=\"relative block\"\n              key={source.id}\n              // The same layoutId as the row's favicon, so the icon travels\n              // instead of one disappearing while another appears.\n              layoutId={isOpen ? undefined : `${layoutPrefix}-${source.id}`}\n              style={{ zIndex: stacked.length - index }}\n              transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n            >\n              <Favicon size={18} source={source} />\n            </motion.span>\n          ))}\n          {overflow > 0 && (\n            <span className=\"ml-1 tabular-nums\">+{overflow}</span>\n          )}\n        </span>\n\n        <span>{label}</span>\n\n        <motion.span\n          animate={{ rotate: isOpen ? 90 : 0 }}\n          className=\"flex size-4 items-center justify-center\"\n          transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n        >\n          <ChevronRight aria-hidden=\"true\" size={14} />\n        </motion.span>\n      </button>\n\n      <AnimatePresence initial={false}>\n        {isOpen ? (\n          <motion.ul\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"mt-1 list-none overflow-hidden\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { height: 0, opacity: 0 }\n            }\n            initial={\n              shouldReduceMotion\n                ? { height: \"auto\", opacity: 1 }\n                : { height: 0, opacity: 0 }\n            }\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    height: SPRING_DEFAULT,\n                    opacity: { duration: 0.18, ease: EASE_OUT },\n                  }\n            }\n          >\n            {sources.map((source, index) => (\n              <motion.li\n                animate={{ opacity: 1, y: 0 }}\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 1, y: 0 }\n                    : { opacity: 0, y: 4 }\n                }\n                key={source.id}\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : { ...SPRING_DEFAULT, delay: index * ROW_STAGGER }\n                }\n              >\n                <a\n                  className=\"flex items-start gap-2.5 rounded-lg px-1 py-1.5 no-underline transition-colors hover:bg-muted\"\n                  href={source.url}\n                  rel=\"noopener noreferrer\"\n                  target=\"_blank\"\n                >\n                  <motion.span\n                    className=\"mt-0.5 block shrink-0\"\n                    layoutId={`${layoutPrefix}-${source.id}`}\n                    transition={\n                      shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT\n                    }\n                  >\n                    <Favicon size={18} source={source} />\n                  </motion.span>\n\n                  <span className=\"min-w-0\">\n                    <span className=\"block truncate font-medium text-foreground text-sm\">\n                      {source.title}\n                    </span>\n                    <span className=\"block truncate text-muted-foreground text-xs\">\n                      {source.snippet ?? hostOf(source.url)}\n                    </span>\n                  </span>\n                </a>\n              </motion.li>\n            ))}\n          </motion.ul>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nexport default AISources;\n","path":"index.tsx","target":"components/smoothui/ai-sources/index.tsx","type":"registry:ui"}],"name":"ai-sources","registryDependencies":[],"title":"Ai Sources","type":"registry:ui"}