{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spotlight-cards","type":"registry:component","title":"Spotlight Cards","description":"A grid of cards lit by one soft light that follows the cursor.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":[],"files":[{"path":"registry/dimaac/spotlight-cards/spotlight-cards.tsx","type":"registry:component","target":"components/dimaac/spotlight-cards.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, type CSSProperties, type ReactNode } from \"react\";\n\n// A grid of cards with one soft spotlight that follows the cursor across the whole grid.\n// It lights each card's border and surface, and the card under the cursor tilts a little.\n// Only the grid listens to the pointer. It writes the spotlight position onto every card as two\n// CSS variables, and the card styles do the rest. No library needed.\n\nexport type SpotlightCard = {\n  /** The card's heading. */\n  title: string;\n  /** One or two sentences under the heading. */\n  body: string;\n  /** A small icon shown in a box above the heading, e.g. an inline SVG. */\n  icon?: ReactNode;\n  /** A picture across the top of the card. */\n  image?: string;\n  /** Alt text for the picture. Leave empty if it is only decoration. */\n  imageAlt?: string;\n};\n\nexport type SpotlightCardsProps = {\n  /** The cards, in reading order. */\n  cards: SpotlightCard[];\n  /** How far the light reaches from the cursor, in pixels. */\n  spotlightSize?: number;\n  /** How bright the light is, from 0 (off) to 1 (strong). */\n  intensity?: number;\n  /** The most a card tilts toward the cursor, in degrees. 0 turns the tilt off. */\n  tilt?: number;\n  /** The colour of the light. Any CSS colour. */\n  color?: string;\n  /** How many columns on a wide screen. Narrow screens use fewer, see minCardWidth. */\n  columns?: number;\n  /** Space between the cards, in pixels. */\n  gap?: number;\n  /** A card never gets narrower than this, in pixels, so phones drop to fewer columns. */\n  minCardWidth?: number;\n  /** Extra classes for the grid. */\n  className?: string;\n  /** Extra classes for each card, e.g. a different radius or padding. */\n  cardClassName?: string;\n};\n\n// The ring is a 1px frame cut out of a filled box with a mask (the middle is subtracted)\nconst ringMask: CSSProperties = {\n  padding: 1,\n  mask: \"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)\",\n  maskComposite: \"exclude\",\n  WebkitMask: \"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)\",\n  WebkitMaskComposite: \"xor\",\n};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n\nexport function SpotlightCards({\n  cards,\n  spotlightSize = 380,\n  intensity = 0.6,\n  tilt = 6,\n  color = \"#14b8a6\",\n  columns = 3,\n  gap = 16,\n  minCardWidth = 160,\n  className = \"\",\n  cardClassName = \"\",\n}: SpotlightCardsProps) {\n  const gridRef = useRef<HTMLUListElement>(null);\n  // Read when the pointer moves, so changing the tilt never rebuilds the listeners\n  const tiltRef = useRef(tilt);\n  tiltRef.current = tilt;\n\n  useEffect(() => {\n    const grid = gridRef.current;\n    if (!grid) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const target = { x: 0, y: 0 };\n    const light = { x: 0, y: 0 };\n    let frame = 0;\n    let lastTime = 0;\n    let inside = false;\n\n    const items = () => Array.from(grid.children) as HTMLElement[];\n\n    // Give every card the light's position measured from its own top-left corner\n    const paintLight = () => {\n      for (const card of items()) {\n        card.style.setProperty(\"--x\", `${light.x - card.offsetLeft}px`);\n        card.style.setProperty(\"--y\", `${light.y - card.offsetTop}px`);\n      }\n    };\n\n    // Only the card under the pointer tilts. The pointer is turned into -1..1 across that card.\n    const paintTilt = () => {\n      const max = reduced ? 0 : tiltRef.current;\n      for (const card of items()) {\n        const px = target.x - card.offsetLeft;\n        const py = target.y - card.offsetTop;\n        const over = inside && px >= 0 && py >= 0 && px <= card.offsetWidth && py <= card.offsetHeight;\n        const across = over ? clamp((px / card.offsetWidth) * 2 - 1, -1, 1) : 0;\n        const down = over ? clamp((py / card.offsetHeight) * 2 - 1, -1, 1) : 0;\n        card.style.setProperty(\"--ry\", `${across * max}deg`);\n        card.style.setProperty(\"--rx\", `${-down * max}deg`);\n        card.style.setProperty(\"--lift\", over && max > 0 ? \"1.012\" : \"1\");\n      }\n    };\n\n    // The light eases toward the pointer, so it glides instead of snapping\n    const step = (now: number) => {\n      const seconds = Math.min(0.05, (now - lastTime) / 1000);\n      lastTime = now;\n      const ease = 1 - Math.exp(-seconds * 18);\n      light.x += (target.x - light.x) * ease;\n      light.y += (target.y - light.y) * ease;\n      paintLight();\n      const close = Math.hypot(target.x - light.x, target.y - light.y) < 0.3;\n      frame = close ? 0 : requestAnimationFrame(step);\n    };\n\n    const follow = (event: PointerEvent, snap: boolean) => {\n      const rect = grid.getBoundingClientRect();\n      target.x = event.clientX - rect.left;\n      target.y = event.clientY - rect.top;\n      if (snap || reduced) {\n        light.x = target.x;\n        light.y = target.y;\n        paintLight();\n      } else if (!frame) {\n        lastTime = performance.now();\n        frame = requestAnimationFrame(step);\n      }\n      paintTilt();\n    };\n\n    const turnOn = (event: PointerEvent) => {\n      if (!event.isPrimary) return;\n      // A fresh entry starts the light under the pointer instead of sliding in from the last spot\n      const fresh = !inside;\n      inside = true;\n      grid.style.setProperty(\"--spot\", \"1\");\n      follow(event, fresh);\n    };\n\n    const turnOff = () => {\n      inside = false;\n      grid.style.setProperty(\"--spot\", \"0\");\n      paintTilt();\n    };\n\n    grid.addEventListener(\"pointermove\", turnOn);\n    grid.addEventListener(\"pointerdown\", turnOn);\n    grid.addEventListener(\"pointerleave\", turnOff);\n    grid.addEventListener(\"pointercancel\", turnOff);\n\n    return () => {\n      grid.removeEventListener(\"pointermove\", turnOn);\n      grid.removeEventListener(\"pointerdown\", turnOn);\n      grid.removeEventListener(\"pointerleave\", turnOff);\n      grid.removeEventListener(\"pointercancel\", turnOff);\n      if (frame) cancelAnimationFrame(frame);\n    };\n  }, []);\n\n  const glowAlpha = clamp(intensity, 0, 1);\n  const mix = (percent: number) => `color-mix(in srgb, ${color} ${Math.round(percent * 100)}%, transparent)`;\n  const light = (percent: number) => `radial-gradient(${spotlightSize}px circle at var(--x, -999px) var(--y, -999px), ${mix(percent)}, transparent)`;\n  const columnCount = Math.max(1, Math.round(columns));\n  // As many columns as fit, up to `columns`, and never narrower than minCardWidth\n  const cardWidth = `max(${minCardWidth}px, calc((100% - ${(columnCount - 1) * gap}px) / ${columnCount}))`;\n\n  return (\n    <ul\n      ref={gridRef}\n      className={`relative m-0 grid w-full list-none p-0 ${className}`}\n      style={{ gridTemplateColumns: `repeat(auto-fit, minmax(${cardWidth}, 1fr))`, gap, touchAction: \"pan-y\", [\"--spot\" as string]: 0 }}\n    >\n      {cards.map((card, index) => (\n        <li\n          key={`${index}-${card.title}`}\n          className={`relative isolate flex flex-col overflow-hidden rounded-2xl ${cardClassName}`}\n          style={{\n            background: \"color-mix(in srgb, currentColor 3.5%, transparent)\",\n            transform: \"perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--lift, 1))\",\n            transition: \"transform 380ms cubic-bezier(0.2, 0.8, 0.2, 1)\",\n            willChange: \"transform\",\n          }}\n        >\n          {/* The always-on hairline border */}\n          <span aria-hidden className=\"pointer-events-none absolute inset-0 z-10 rounded-[inherit]\" style={{ ...ringMask, background: \"color-mix(in srgb, currentColor 11%, transparent)\" }} />\n\n          {card.image ? (\n            <div className=\"aspect-[5/2] w-full overflow-hidden\">\n              <img src={card.image} alt={card.imageAlt ?? \"\"} className=\"block h-full w-full object-cover\" draggable={false} loading=\"lazy\" decoding=\"async\" />\n            </div>\n          ) : null}\n\n          <div className=\"flex flex-1 flex-col gap-3 p-5 sm:p-6\">\n            {card.icon ? (\n              <span\n                aria-hidden\n                className=\"grid h-10 w-10 shrink-0 place-items-center rounded-xl [&>svg]:h-5 [&>svg]:w-5\"\n                style={{ background: \"color-mix(in srgb, currentColor 6%, transparent)\", boxShadow: \"inset 0 0 0 1px color-mix(in srgb, currentColor 10%, transparent)\" }}\n              >\n                {card.icon}\n              </span>\n            ) : null}\n            <h3 className=\"m-0 text-[17px] leading-tight font-medium tracking-[-0.01em]\">{card.title}</h3>\n            <p className=\"m-0 text-[14px] leading-[1.55] opacity-65\">{card.body}</p>\n          </div>\n\n          {/* The light on the surface, then the light on the border. Both fade with --spot. */}\n          <span aria-hidden className=\"pointer-events-none absolute inset-0 z-10 rounded-[inherit]\" style={{ background: light(glowAlpha * 0.34), opacity: \"var(--spot)\", transition: \"opacity 450ms ease\" }} />\n          <span aria-hidden className=\"pointer-events-none absolute inset-0 z-10 rounded-[inherit]\" style={{ ...ringMask, background: light(Math.min(1, glowAlpha * 1.35)), opacity: \"var(--spot)\", transition: \"opacity 450ms ease\" }} />\n        </li>\n      ))}\n    </ul>\n  );\n}\n"}],"categories":["cards","cursor","spotlight","tilt","css"],"docs":"import { SpotlightCards } from \"@/components/dimaac/spotlight-cards\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/spotlight-cards","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/spotlight-cards","credits":[{"by":"Mikiwa","url":"https://www.lummi.ai/photo/modern-building-corner-dr5dz","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Mariate","url":"https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}