{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-glide-cards",
  "title": "Hover Glide Cards",
  "author": "henriquegpb <https://github.com/henriquegpb>",
  "description": "One highlight glides from card to card under the pointer, seen only through the cards themselves, so it drains out of one and fills the next.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/microkit/hover-glide-cards.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { CalendarDays, FolderOpen, Mail, NotebookPen } from \"lucide-react\";\n\nconst apps = [\n  { name: \"Calendar\", detail: \"Plan your week\", icon: CalendarDays },\n  { name: \"Mail\", detail: \"Triage every inbox\", icon: Mail },\n  { name: \"Notes\", detail: \"Draft from meetings\", icon: NotebookPen },\n  { name: \"Files\", detail: \"Find shared files\", icon: FolderOpen },\n];\n\nexport function HoverGlideCards() {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const layerRef = useRef<HTMLSpanElement>(null);\n  const glideRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    const layer = layerRef.current;\n    const glide = glideRef.current;\n    if (!host || !layer || !glide) return;\n\n    const tiles = Array.from(host.querySelectorAll(\"[data-glide-card]\"));\n    const controller = new AbortController();\n    const { signal } = controller;\n    let active = -1;\n    let shown = false;\n\n    // The highlight is only seen through the cards' shapes, so crossing a gap it drains out of one card and fills the next.\n    const drawMask = () => {\n      const bounds = host.getBoundingClientRect();\n      const shapes = tiles\n        .map((tile) => {\n          const rect = tile.getBoundingClientRect();\n          const x = rect.left - bounds.left + host.scrollLeft - host.clientLeft;\n          const y = rect.top - bounds.top + host.scrollTop - host.clientTop;\n          const radius = parseFloat(getComputedStyle(tile).borderTopLeftRadius) || 0;\n          return `<rect x=\"${x}\" y=\"${y}\" width=\"${rect.width}\" height=\"${rect.height}\" rx=\"${radius}\"/>`;\n        })\n        .join(\"\");\n      const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${host.scrollWidth}\" height=\"${host.scrollHeight}\">${shapes}</svg>`;\n      const image = `url(\"data:image/svg+xml,${encodeURIComponent(svg)}\")`;\n      layer.style.width = `${host.scrollWidth}px`;\n      layer.style.height = `${host.scrollHeight}px`;\n      layer.style.setProperty(\"mask-image\", image);\n      layer.style.setProperty(\"-webkit-mask-image\", image);\n    };\n\n    const place = () => {\n      const tile = tiles[active];\n      if (!tile) return;\n      const bounds = host.getBoundingClientRect();\n      const rect = tile.getBoundingClientRect();\n      const x = rect.left - bounds.left + host.scrollLeft - host.clientLeft;\n      const y = rect.top - bounds.top + host.scrollTop - host.clientTop;\n      // The first placement fades in where it lands; sliding in from wherever it last was would read as a glitch.\n      if (!shown) glide.style.transition = \"opacity 180ms ease\";\n      glide.style.transform = `translate(${x}px, ${y}px)`;\n      glide.style.width = `${rect.width}px`;\n      glide.style.height = `${rect.height}px`;\n      if (!shown) {\n        void glide.offsetWidth;\n        glide.style.transition = \"\";\n        shown = true;\n      }\n      glide.dataset.visible = \"\";\n    };\n\n    const hide = () => {\n      shown = false;\n      delete glide.dataset.visible;\n    };\n\n    for (const type of [\"pointerover\", \"focusin\"]) {\n      host.addEventListener(\n        type,\n        (event) => {\n          const tile = event.target instanceof Element ? event.target.closest(\"[data-glide-card]\") : null;\n          const index = tile ? tiles.indexOf(tile) : -1;\n          if (index < 0) return;\n          if (!shown || index !== active) drawMask();\n          active = index;\n          place();\n        },\n        { signal },\n      );\n    }\n    host.addEventListener(\"pointerleave\", hide, { signal });\n    host.addEventListener(\n      \"focusout\",\n      (event) => {\n        const inside = event.relatedTarget instanceof Node && host.contains(event.relatedTarget);\n        if (!inside && !host.matches(\":hover\")) hide();\n      },\n      { signal },\n    );\n\n    // Cards reflow under a resting pointer when the grid resizes, so the cut-outs and the highlight are measured again.\n    const reflow = new ResizeObserver(() => {\n      if (!shown) return;\n      drawMask();\n      place();\n    });\n    reflow.observe(host);\n\n    return () => {\n      controller.abort();\n      reflow.disconnect();\n    };\n  }, []);\n\n  return (\n    <div className=\"@container relative grid w-full max-w-[240px] grid-cols-2 gap-[6px]\" role=\"group\" aria-label=\"Workspace apps\" ref={hostRef}>\n      <span className=\"pointer-events-none absolute left-0 top-0 z-[1] block [mask-repeat:no-repeat] [-webkit-mask-repeat:no-repeat]\" ref={layerRef} aria-hidden=\"true\">\n        <span className=\"pointer-events-none absolute left-0 top-0 block h-0 w-0 rounded-[10px] bg-[light-dark(rgba(20,24,30,.055),rgba(255,255,255,.045))] opacity-0 [transition:transform_.3s_cubic-bezier(.32,.72,0,1),width_.3s_cubic-bezier(.32,.72,0,1),height_.3s_cubic-bezier(.32,.72,0,1),opacity_.18s_ease] data-[visible]:opacity-100 motion-reduce:[transition:opacity_.01ms] [@media(hover:none)]:hidden\" ref={glideRef} />\n      </span>\n      {apps.map((app) => (\n        <button type=\"button\" className=\"m-0 flex min-w-0 cursor-pointer flex-col items-start rounded-[10px] border border-[light-dark(#e3e6eb,#24272d)] bg-[light-dark(#ffffff,#121418)] p-[8px] text-left text-[color:light-dark(#14181e,#f2f4f6)] [font:inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff]\" data-glide-card=\"\" key={app.name}>\n          <span className=\"mb-[6px] grid size-[20px] place-items-center rounded-[6px] bg-[light-dark(#f0f2f5,#1b1e23)] text-[color:light-dark(#69727f,#868d97)]\">\n            <app.icon size={12} strokeWidth={1.8} aria-hidden=\"true\" />\n          </span>\n          <span className=\"max-w-full truncate text-[12px] font-semibold leading-[15px]\">{app.name}</span>\n          <span className=\"mt-[1px] max-w-full truncate text-[11px] leading-[13px] text-[color:light-dark(#69727f,#868d97)] @max-[236px]:hidden\">{app.detail}</span>\n        </button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/microkit/hover-glide-cards.tsx"
    }
  ],
  "docs": "Live preview and the CSS, JavaScript and Tailwind variants: https://microkit.co/components/hover-glide-cards",
  "categories": [
    "navigation",
    "hover"
  ],
  "type": "registry:component"
}