Spotlight
Move your cursor across the card. A soft halo tracks it, and the border catches the light as it passes.
Experiment #006
Alpine.js reads the cursor position relative to the card and maps it to rotateX/rotateY, clamped at 8 degrees, inside a parent perspective. The card carries transform-style: preserve-3d and its content sits on a slight translateZ, so the inner layer floats above the surface as it tilts. A 150ms transition keeps tracking tight while hovering; on leave the duration switches to 400ms for a soft settle. The bounding rect is cached on mouseenter so the 3D projection never feeds back into the math.
Plate 03
Leans toward the cursor, settles flat when it leaves.
Lab notes
Tune the max angle (6–10deg) and the perspective (600–1000px): a shorter perspective exaggerates depth, a longer one flattens it. Keep translateZ on the content modest (20–40px) or text starts to blur at the edges. The perspective must live on the parent, not the card itself, or the rotation loses its vanishing point.
<!-- Tilt Card — Tailwind CSS + Alpine.js -->
<div class="inline-flex p-10 [perspective:800px]">
<div
x-data="{
rx: 0,
ry: 0,
hovering: false,
rect: null,
enter() {
this.rect = this.$el.getBoundingClientRect()
this.hovering = true
},
tilt(event) {
if (! this.rect) return
this.rx = (0.5 - (event.clientY - this.rect.top) / this.rect.height) * 16
this.ry = ((event.clientX - this.rect.left) / this.rect.width - 0.5) * 16
},
reset() {
this.hovering = false
this.rect = null
this.rx = 0
this.ry = 0
},
}"
@mouseenter="enter"
@mousemove="tilt"
@mouseleave="reset"
:style="`transform: rotateX(${rx}deg) rotateY(${ry}deg); transition-duration: ${hovering ? 150 : 400}ms`"
class="w-64 border border-zinc-300 bg-white p-6
transition-transform ease-out will-change-transform
[transform-style:preserve-3d]
motion-reduce:transition-none motion-reduce:transform-none!"
>
<!-- Content sits on translateZ for depth; keep it modest (20-40px). -->
<div class="[transform:translateZ(30px)] motion-reduce:transform-none!">
<p class="font-mono text-xs uppercase tracking-widest text-zinc-500">Plate 03</p>
<h3 class="mt-3 text-lg font-semibold text-zinc-900">Tilt Card</h3>
<p class="mt-1 text-sm text-zinc-600">Leans toward the cursor, settles flat when it leaves.</p>
</div>
</div>
</div>
// Tilt Card — reusable Alpine.js component
//
// Register before Alpine starts:
//
// document.addEventListener('alpine:init', () => {
// Alpine.data('tiltCard', tiltCard)
// })
//
// Usage — the perspective must live on a PARENT of the card:
//
// <div class="[perspective:800px]">
// <div
// x-data="tiltCard(8)"
// @mouseenter="enter"
// @mousemove="tilt"
// @mouseleave="reset"
// :style="tiltStyle"
// class="transition-transform ease-out will-change-transform
// [transform-style:preserve-3d]
// motion-reduce:transition-none motion-reduce:transform-none!"
// >
// <div class="[transform:translateZ(30px)] motion-reduce:transform-none!">
// ...
// </div>
// </div>
// </div>
export default function tiltCard(maxDeg = 8) {
return {
rx: 0,
ry: 0,
hovering: false,
rect: null,
enter() {
// Cache the rect while the card is still flat, so the 3D
// projection never feeds back into the tilt math.
this.rect = this.$el.getBoundingClientRect()
this.hovering = true
},
tilt(event) {
if (! this.rect) return
const px = (event.clientX - this.rect.left) / this.rect.width
const py = (event.clientY - this.rect.top) / this.rect.height
this.rx = (0.5 - py) * maxDeg * 2
this.ry = (px - 0.5) * maxDeg * 2
},
reset() {
this.hovering = false
this.rect = null
this.rx = 0
this.ry = 0
},
get tiltStyle() {
// 150ms tracks tightly while hovering; 400ms settles softly on leave.
return `transform: rotateX(${this.rx}deg) rotateY(${this.ry}deg); transition-duration: ${this.hovering ? 150 : 400}ms`
},
}
}
Adjacent
Move your cursor across the card. A soft halo tracks it, and the border catches the light as it passes.