{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reveal-card-grid",
  "type": "registry:ui",
  "title": "Reveal Card Grid",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Interactive feature card grid with dynamic circular ink reveals that expand from the pointer entry coordinates to display vivid marbled fluid textures.",
  "dependencies": [
    "framer-motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/reveal-card-grid.tsx",
      "content": "\"use client\";\n\nimport React, { useRef, useState, useId } from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useTransform,\n  animate,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n} from \"framer-motion\";\nimport {\n  Rocket,\n  Zap,\n  Palette,\n  Globe,\n  Layers,\n  Link as LinkIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/* Types & Accent Config                                              */\n/* ------------------------------------------------------------------ */\n\nexport type AccentColor =\n  | \"blue\"\n  | \"purple\"\n  | \"pink\"\n  | \"green\"\n  | \"orange\"\n  | \"red\";\n\nexport interface RevealCardItem {\n  id: string | number;\n  title: string;\n  description: string;\n  icon: React.ComponentType<{ className?: string }>;\n  accent: AccentColor;\n  href?: string;\n  onClick?: () => void;\n  image?: string;\n}\n\nexport interface RevealCardGridProps {\n  /** Array of feature card items (defaults to 6 items) */\n  items?: RevealCardItem[];\n  /** Optional section header title */\n  title?: string;\n  /** Optional section subtitle */\n  subtitle?: string;\n  className?: string;\n}\n\nexport interface RevealCardProps {\n  item: RevealCardItem;\n  className?: string;\n}\n\n// Complete static class strings to allow Tailwind compiler detection without dynamic truncation\nexport const ACCENT_STYLES: Record<\n  AccentColor,\n  {\n    idleChipBg: string;\n    idleIcon: string;\n    hoverChipBg: string;\n    hoverIcon: string;\n    chipShadow: string;\n    ring: string;\n    stops: [string, string, string, string]; // [highlight, midtone, deepShadow, nearBlack]\n  }\n> = {\n  blue: {\n    idleChipBg: \"bg-blue-500/10 dark:bg-blue-500/15\",\n    idleIcon: \"text-blue-600/70 dark:text-blue-400/70\",\n    hoverChipBg: \"bg-white text-blue-600 shadow-md shadow-blue-500/20\",\n    hoverIcon: \"text-blue-600\",\n    chipShadow: \"shadow-blue-500/20\",\n    ring: \"focus-visible:ring-blue-500\",\n    stops: [\"#38bdf8\", \"#2563eb\", \"#1e1b4b\", \"#020617\"],\n  },\n  purple: {\n    idleChipBg: \"bg-purple-500/10 dark:bg-purple-500/15\",\n    idleIcon: \"text-purple-600/70 dark:text-purple-400/70\",\n    hoverChipBg: \"bg-white text-purple-600 shadow-md shadow-purple-500/20\",\n    hoverIcon: \"text-purple-600\",\n    chipShadow: \"shadow-purple-500/20\",\n    ring: \"focus-visible:ring-purple-500\",\n    stops: [\"#c084fc\", \"#9333ea\", \"#2e1065\", \"#090514\"],\n  },\n  pink: {\n    idleChipBg: \"bg-pink-500/10 dark:bg-pink-500/15\",\n    idleIcon: \"text-pink-600/70 dark:text-pink-400/70\",\n    hoverChipBg: \"bg-white text-pink-600 shadow-md shadow-pink-500/20\",\n    hoverIcon: \"text-pink-600\",\n    chipShadow: \"shadow-pink-500/20\",\n    ring: \"focus-visible:ring-pink-500\",\n    stops: [\"#fb7185\", \"#e11d48\", \"#4c0519\", \"#0f0206\"],\n  },\n  green: {\n    idleChipBg: \"bg-emerald-500/10 dark:bg-emerald-500/15\",\n    idleIcon: \"text-emerald-600/70 dark:text-emerald-400/70\",\n    hoverChipBg: \"bg-white text-emerald-600 shadow-md shadow-emerald-500/20\",\n    hoverIcon: \"text-emerald-600\",\n    chipShadow: \"shadow-emerald-500/20\",\n    ring: \"focus-visible:ring-emerald-500\",\n    stops: [\"#34d399\", \"#059669\", \"#064e3b\", \"#021a11\"],\n  },\n  orange: {\n    idleChipBg: \"bg-amber-500/10 dark:bg-amber-500/15\",\n    idleIcon: \"text-amber-600/70 dark:text-amber-400/70\",\n    hoverChipBg: \"bg-white text-amber-600 shadow-md shadow-amber-500/20\",\n    hoverIcon: \"text-amber-600\",\n    chipShadow: \"shadow-amber-500/20\",\n    ring: \"focus-visible:ring-amber-500\",\n    stops: [\"#fbbf24\", \"#ea580c\", \"#431407\", \"#0c0402\"],\n  },\n  red: {\n    idleChipBg: \"bg-rose-500/10 dark:bg-rose-500/15\",\n    idleIcon: \"text-rose-600/70 dark:text-rose-400/70\",\n    hoverChipBg: \"bg-white text-rose-600 shadow-md shadow-rose-500/20\",\n    hoverIcon: \"text-rose-600\",\n    chipShadow: \"shadow-rose-500/20\",\n    ring: \"focus-visible:ring-rose-500\",\n    stops: [\"#f87171\", \"#dc2626\", \"#450a0a\", \"#0a0101\"],\n  },\n};\n\n/* ------------------------------------------------------------------ */\n/* Default 6 Items                                                    */\n/* ------------------------------------------------------------------ */\n\nexport const DEFAULT_REVEAL_ITEMS: RevealCardItem[] = [\n  {\n    id: \"instant-deploys\",\n    title: \"Instant deploys\",\n    description: \"Push to main and it's live worldwide before the kettle boils.\",\n    icon: Rocket,\n    accent: \"blue\",\n  },\n  {\n    id: \"edge-functions\",\n    title: \"Edge functions\",\n    description:\n      \"Run code a few milliseconds from every visitor, not an ocean away.\",\n    icon: Zap,\n    accent: \"purple\",\n  },\n  {\n    id: \"design-tokens\",\n    title: \"Design tokens\",\n    description:\n      \"One source of colour, type and spacing that every surface reads from.\",\n    icon: Palette,\n    accent: \"pink\",\n  },\n  {\n    id: \"global-cdn\",\n    title: \"Global CDN\",\n    description:\n      \"Assets cached in hundreds of cities and purged in under a second.\",\n    icon: Globe,\n    accent: \"green\",\n  },\n  {\n    id: \"media-pipeline\",\n    title: \"Media pipeline\",\n    description:\n      \"Images and video resized, compressed and served in the right format.\",\n    icon: Layers,\n    accent: \"orange\",\n  },\n  {\n    id: \"preview-links\",\n    title: \"Preview links\",\n    description:\n      \"Every branch gets its own URL, so reviews happen on the real thing.\",\n    icon: LinkIcon,\n    accent: \"red\",\n  },\n];\n\n/* ------------------------------------------------------------------ */\n/* Subcomponent: Procedural Marbled Liquid Fluid Canvas                */\n/* ------------------------------------------------------------------ */\n\nexport function MarbledFluidCanvas({\n  accent,\n  isHovered,\n}: {\n  accent: AccentColor;\n  isHovered: boolean;\n}) {\n  const rawId = useId();\n  const filterId = `marble-fluid-${accent}-${rawId.replace(/:/g, \"\")}`;\n  const stops = ACCENT_STYLES[accent].stops;\n\n  return (\n    <div className=\"relative h-full w-full overflow-hidden bg-black select-none\">\n      <svg\n        className=\"absolute inset-0 h-full w-full\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n        preserveAspectRatio=\"none\"\n      >\n        <defs>\n          <filter id={filterId} x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">\n            <feTurbulence\n              type=\"fractalNoise\"\n              baseFrequency=\"0.012\"\n              numOctaves=\"4\"\n              result=\"noise\"\n            />\n            <feDisplacementMap\n              in=\"SourceGraphic\"\n              in2=\"noise\"\n              scale=\"46\"\n              xChannelSelector=\"R\"\n              yChannelSelector=\"G\"\n            />\n          </filter>\n        </defs>\n\n        <motion.g\n          filter={`url(#${filterId})`}\n          animate={\n            isHovered\n              ? {\n                  scale: [1, 1.05, 1],\n                  rotate: [0, 2, 0],\n                }\n              : undefined\n          }\n          transition={{\n            duration: 18,\n            repeat: Infinity,\n            ease: \"easeInOut\",\n          }}\n          className=\"origin-center\"\n        >\n          {/* Deep Near-Black Fluid Base */}\n          <rect width=\"100%\" height=\"100%\" fill={stops[3]} />\n\n          {/* Liquid swirls in mapped gradient stops */}\n          <circle cx=\"20%\" cy=\"25%\" r=\"58%\" fill={stops[1]} opacity=\"0.9\" />\n          <circle cx=\"85%\" cy=\"30%\" r=\"52%\" fill={stops[0]} opacity=\"0.85\" />\n          <circle cx=\"70%\" cy=\"80%\" r=\"65%\" fill={stops[2]} opacity=\"0.95\" />\n          <circle cx=\"25%\" cy=\"85%\" r=\"48%\" fill={stops[1]} opacity=\"0.8\" />\n          <circle cx=\"50%\" cy=\"50%\" r=\"38%\" fill={stops[0]} opacity=\"0.75\" />\n        </motion.g>\n      </svg>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Subcomponent: Reusable RevealCard                                  */\n/* ------------------------------------------------------------------ */\n\nexport function RevealCard({ item, className }: RevealCardProps) {\n  const cardRef = useRef<HTMLLIElement>(null);\n  const [isHovered, setIsHovered] = useState(false);\n  const controlsRef = useRef<AnimationPlaybackControls | null>(null);\n  const shouldReduceMotion = useReducedMotion();\n\n  // Dynamic ink circle clip-path motion values\n  const originX = useMotionValue(0);\n  const originY = useMotionValue(0);\n  const radius = useMotionValue(0);\n\n  // Transform into CSS clip-path circle(R at X Y)\n  const clipPath = useTransform(\n    [originX, originY, radius],\n    ([x, y, r]) => `circle(${r}px at ${x}px ${y}px)`\n  );\n\n  const style = ACCENT_STYLES[item.accent];\n  const Icon = item.icon;\n\n  /* ------------------------------------------------------------------ */\n  /* Pointer Entrance & Exit Physics Logic                              */\n  /* ------------------------------------------------------------------ */\n\n  const handlePointerEnter = (e: React.PointerEvent<HTMLLIElement>) => {\n    if (e.pointerType === \"touch\") return;\n    const rect = cardRef.current?.getBoundingClientRect();\n    if (!rect) return;\n\n    // Entry point coordinates relative to the card bounds\n    const x = e.clientX - rect.left;\n    const y = e.clientY - rect.top;\n    originX.set(x);\n    originY.set(y);\n\n    // Compute maximum distance to the farthest corner so the circle covers 100% of the surface\n    const dx = Math.max(x, rect.width - x);\n    const dy = Math.max(y, rect.height - y);\n    const maxRadius = Math.hypot(dx, dy);\n\n    // Stop ongoing animation and resume from current radius without jump\n    controlsRef.current?.stop();\n    setIsHovered(true);\n\n    controlsRef.current = animate(radius, maxRadius, {\n      duration: 0.65,\n      ease: [0.22, 1, 0.36, 1],\n    });\n  };\n\n  const handlePointerLeave = (e: React.PointerEvent<HTMLLIElement>) => {\n    if (e.pointerType === \"touch\") return;\n    const rect = cardRef.current?.getBoundingClientRect();\n    if (!rect) return;\n\n    // Clamp exit pointer coordinates to card perimeter\n    const exitX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));\n    const exitY = Math.max(0, Math.min(rect.height, e.clientY - rect.top));\n\n    // Move circle origin to the exit point so ink visibly drains toward the cursor\n    originX.set(exitX);\n    originY.set(exitY);\n\n    controlsRef.current?.stop();\n    setIsHovered(false);\n\n    // Animate radius smoothly back to 0\n    controlsRef.current = animate(radius, 0, {\n      duration: 0.5,\n      ease: [0.22, 1, 0.36, 1],\n    });\n  };\n\n  /* ------------------------------------------------------------------ */\n  /* Keyboard Accessibility (Focus / Blur from Card Center)             */\n  /* ------------------------------------------------------------------ */\n\n  const handleFocus = () => {\n    const rect = cardRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const cx = rect.width / 2;\n    const cy = rect.height / 2;\n    originX.set(cx);\n    originY.set(cy);\n    const maxRadius = Math.hypot(cx, cy);\n\n    controlsRef.current?.stop();\n    setIsHovered(true);\n    controlsRef.current = animate(radius, maxRadius, {\n      duration: 0.65,\n      ease: [0.22, 1, 0.36, 1],\n    });\n  };\n\n  const handleBlur = () => {\n    const rect = cardRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const cx = rect.width / 2;\n    const cy = rect.height / 2;\n    originX.set(cx);\n    originY.set(cy);\n\n    controlsRef.current?.stop();\n    setIsHovered(false);\n    controlsRef.current = animate(radius, 0, {\n      duration: 0.5,\n      ease: [0.22, 1, 0.36, 1],\n    });\n  };\n\n  /* ------------------------------------------------------------------ */\n  /* Touch Screen Tap Handling                                          */\n  /* ------------------------------------------------------------------ */\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLLIElement>) => {\n    if (e.pointerType !== \"touch\") return;\n    const rect = cardRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const x = e.clientX - rect.left;\n    const y = e.clientY - rect.top;\n    originX.set(x);\n    originY.set(y);\n    const maxRadius = Math.hypot(\n      Math.max(x, rect.width - x),\n      Math.max(y, rect.height - y)\n    );\n\n    controlsRef.current?.stop();\n    if (!isHovered) {\n      setIsHovered(true);\n      controlsRef.current = animate(radius, maxRadius, {\n        duration: 0.6,\n        ease: [0.22, 1, 0.36, 1],\n      });\n    } else {\n      setIsHovered(false);\n      controlsRef.current = animate(radius, 0, {\n        duration: 0.45,\n        ease: [0.22, 1, 0.36, 1],\n      });\n    }\n  };\n\n  return (\n    <li\n      ref={cardRef}\n      tabIndex={0}\n      role={item.href || item.onClick ? \"button\" : \"article\"}\n      aria-label={`${item.title}: ${item.description}`}\n      onPointerEnter={handlePointerEnter}\n      onPointerLeave={handlePointerLeave}\n      onPointerDown={handlePointerDown}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      onClick={item.onClick}\n      className={cn(\n        \"group relative flex min-h-[190px] sm:min-h-[210px] flex-col justify-between overflow-hidden rounded-[24px] border p-5 sm:p-6 transition-all duration-300 select-none\",\n        \"bg-white dark:bg-neutral-900/60 border-black/[0.06] dark:border-white/[0.08] shadow-[0_2px_12px_rgba(0,0,0,0.03)] dark:shadow-none\",\n        \"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2\",\n        style.ring,\n        item.href || item.onClick ? \"cursor-pointer\" : \"cursor-default\",\n        className\n      )}\n    >\n      {/* Absolutely positioned marbled fluid artwork layer masked by circular clip-path */}\n      <motion.div\n        aria-hidden=\"true\"\n        style={{\n          clipPath: shouldReduceMotion ? undefined : clipPath,\n          opacity: shouldReduceMotion ? (isHovered ? 1 : 0) : 1,\n        }}\n        className=\"pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-[24px] transition-opacity duration-300\"\n      >\n        {item.image ? (\n          <img\n            src={item.image}\n            alt=\"\"\n            className=\"h-full w-full object-cover\"\n          />\n        ) : (\n          <MarbledFluidCanvas accent={item.accent} isHovered={isHovered} />\n        )}\n\n        {/* Bottom dark gradient overlay ensuring 100% white text contrast */}\n        <div className=\"absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent\" />\n      </motion.div>\n\n      {/* Top-left: Circular Icon Chip */}\n      <div className=\"relative z-10 flex items-center justify-between\">\n        <div\n          className={cn(\n            \"flex h-10 w-10 items-center justify-center rounded-full transition-all duration-300\",\n            isHovered ? style.hoverChipBg : cn(style.idleChipBg, style.idleIcon)\n          )}\n        >\n          <Icon className=\"h-5 w-5 transition-transform duration-300 group-hover:scale-105\" />\n        </div>\n      </div>\n\n      {/* Bottom-left: Title and Description */}\n      <div className=\"relative z-10 mt-6 flex flex-col justify-end\">\n        <h3\n          className={cn(\n            \"text-base font-semibold tracking-tight transition-colors duration-300\",\n            isHovered\n              ? \"text-white\"\n              : \"text-neutral-600 dark:text-neutral-300\"\n          )}\n        >\n          {item.title}\n        </h3>\n        <p\n          className={cn(\n            \"mt-1.5 text-xs sm:text-sm leading-relaxed transition-colors duration-300\",\n            isHovered\n              ? \"text-white/80\"\n              : \"text-neutral-400 dark:text-neutral-500\"\n          )}\n        >\n          {item.description}\n        </p>\n      </div>\n    </li>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Main Component: RevealCardGrid                                     */\n/* ------------------------------------------------------------------ */\n\nexport function RevealCardGrid({\n  items = DEFAULT_REVEAL_ITEMS,\n  title,\n  subtitle,\n  className,\n}: RevealCardGridProps) {\n  return (\n    <section\n      aria-label={title ?? \"Features\"}\n      className={cn(\n        \"relative w-full max-w-6xl mx-auto px-4 py-8 sm:py-12\",\n        className\n      )}\n    >\n      {/* Optional Section Header */}\n      {(title || subtitle) && (\n        <div className=\"mb-8 sm:mb-12 text-center max-w-2xl mx-auto\">\n          {title && (\n            <h2 className=\"text-2xl sm:text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100\">\n              {title}\n            </h2>\n          )}\n          {subtitle && (\n            <p className=\"mt-2 text-sm sm:text-base text-neutral-500 dark:text-neutral-400 leading-relaxed\">\n              {subtitle}\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Semantic Grid Container: 1 col mobile, 2 col tablet, 3 col desktop */}\n      <ul className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 list-none p-0 m-0\">\n        {items.map((item) => (\n          <RevealCard key={item.id} item={item} />\n        ))}\n      </ul>\n    </section>\n  );\n}\n\nexport default RevealCardGrid;\n",
      "type": "registry:ui",
      "target": "components/ui/reveal-card-grid.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
