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.
---
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>