Skip to content
Menu

Experiment #006

Tilt Card

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.

Scene

Technical details

Experiment
FX-006
Category
Cards
Stack
Tailwind + Alpine.js
Difficulty
Intermediate
Dependencies
none
Weight
HTML 1.7 KB · ALPINE 1.9 KB
Added
2026-08-25
Updated
2026-08-25

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.

Take the code

HTML — code.html
<!-- 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>
Alpine — alpine.js
// 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

Specimens nearby

All Cards →
FX-005 Cards

Spotlight

Move your cursor across the card. A soft halo tracks it, and the border catches the light as it passes.