Spotlight Tilt Cards

A row of cards that tilt in 3D toward the cursor while a radial spotlight tracks the pointer. A shared group :has() dims the siblings you're not hovering.

  • perspective
  • pointer events
  • :has()
  • CSS custom props

Source

A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.

cards/spotlight-tilt.astro
---
export const meta = {
  title: "Spotlight Tilt Cards",
  tags: ["cards", "3d", "hover", "pointer"],
  description:
    "A row of cards that tilt in 3D toward the cursor while a radial spotlight tracks the pointer. A shared group :has() dims the siblings you're not hovering.",
  tech: ["perspective", "pointer events", ":has()", "CSS custom props"],
  interactive: true,
  height: 460,
};

const cards = [
  { icon: "◈", title: "Realtime sync", body: "Sub-50ms updates across every connected client, backed by CRDTs." },
  { icon: "◇", title: "Edge native", body: "Deployed to 300+ locations. Your users never wait on a round-trip." },
  { icon: "◆", title: "Type-safe", body: "End-to-end types from database to component. Refactor without fear." },
];
---

<section class="tilt-section">
  <div class="tilt-grid" data-tilt-grid>
    {cards.map((c) => (
      <article class="tilt-card" data-tilt>
        <div class="tilt-card__inner">
          <span class="tilt-card__icon">{c.icon}</span>
          <h3>{c.title}</h3>
          <p>{c.body}</p>
        </div>
      </article>
    ))}
  </div>
</section>

<style>
  .tilt-section { display: grid; place-items: center; min-height: 460px; padding: 3rem 1.5rem; background: var(--bg-sunk, #0b0c15); }
  .tilt-grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); max-width: 60rem; width: 100%; perspective: 1000px; }

  .tilt-card {
    --rx: 0deg; --ry: 0deg; --mx: 50%; --my: 50%;
    position: relative; border-radius: 20px; padding: 1px;
    background: linear-gradient(var(--bg, #14151f), var(--bg, #14151f)) padding-box,
                conic-gradient(from 180deg at var(--mx) var(--my), var(--spectrum-1,#8b5cf6), var(--spectrum-3,#45d3e8), var(--spectrum-5,#fbbf24), var(--spectrum-1,#8b5cf6)) border-box;
    border: 1px solid transparent;
    transform: rotateX(var(--rx)) rotateY(var(--ry));
    transform-style: preserve-3d;
    transition: transform 0.25s ease, opacity 0.3s ease;
    will-change: transform;
  }
  .tilt-card__inner {
    position: relative; border-radius: 19px; padding: 1.6rem; height: 100%;
    background:
      radial-gradient(18rem 18rem at var(--mx) var(--my), color-mix(in oklab, var(--spectrum-3,#45d3e8) 22%, transparent), transparent 60%),
      var(--surface, #14151f);
    display: grid; gap: 0.6rem; align-content: start;
  }
  .tilt-card__icon { font-size: 1.8rem; transform: translateZ(40px); color: var(--spectrum-3, #45d3e8); }
  .tilt-card h3 { margin: 0; font-size: 1.25rem; transform: translateZ(28px); color: var(--ink, #fff); }
  .tilt-card p { margin: 0; color: var(--ink-muted, #b7b9c9); line-height: 1.5; transform: translateZ(14px); }

  /* dim the cards you're NOT pointing at */
  .tilt-grid:has(.tilt-card:hover) .tilt-card:not(:hover) { opacity: 0.55; }

  @media (prefers-reduced-motion: reduce) { .tilt-card { transition: opacity 0.3s ease; } }
</style>

<script>
  (function () {
    function bind() {
      document.querySelectorAll<HTMLElement>("[data-tilt]").forEach((card) => {
        if (card.dataset.bound) return;
        card.dataset.bound = "1";
        const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
        card.addEventListener("pointermove", (e) => {
          const r = card.getBoundingClientRect();
          const px = (e.clientX - r.left) / r.width;
          const py = (e.clientY - r.top) / r.height;
          card.style.setProperty("--mx", `${px * 100}%`);
          card.style.setProperty("--my", `${py * 100}%`);
          if (!reduce) {
            card.style.setProperty("--ry", `${(px - 0.5) * 16}deg`);
            card.style.setProperty("--rx", `${(0.5 - py) * 16}deg`);
          }
        });
        card.addEventListener("pointerleave", () => {
          card.style.setProperty("--rx", "0deg");
          card.style.setProperty("--ry", "0deg");
        });
      });
    }
    document.addEventListener("astro:page-load", bind);
    if (document.readyState !== "loading") bind();
  })();
</script>