More Cards Snippets
Wobble Card — Free HTML CSS JS Jelly Hover Snippet
Wobble Card · Cards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Wobble Card — Jelly Pointer-Follow with Counter-Parallax

The wobble card is the playful hover effect where a card squishes and leans toward your pointer like a block of soft jelly, while its contents drift the opposite way for a gooey parallax — then everything springs back when you leave. This snippet builds it with plain HTML, CSS, and one vanilla JavaScript pointer handler, relying on a spring-like easing curve for the bouncy feel.
Two layers moving in opposition
The effect's secret is that the shell and the content move in opposite directions. A pointermove handler converts the cursor position to -0.5…0.5 fractions, then translates and tilts the outer .wo-card toward the pointer (translate3d plus rotateX/rotateY), while translating the inner .wo-inner by a larger amount in the negative direction. Because the content slides against the card's lean, the surface appears to stretch and the text appears to resist — the visual signature of squishy, deformable material rather than a rigid tilt.
The spring easing
Both layers transition with cubic-bezier(.2, .8, .2, 1), an ease-out curve that overshoots slightly and settles — the closest you get to a spring without a physics library. During movement the short .2s transition keeps the card chasing the pointer with a soft lag, and on release the same curve makes it bounce back to rest rather than snapping linearly. This single easing choice is what makes it feel like jelly instead of glass.
Perspective and preserve-3d
The stage sets perspective: 1200px and the card uses transform-style: preserve-3d, so the rotateX/rotateY produce genuine depth and the content's counter-translation reads as parallax within the card rather than a flat slide. Without the perspective parent, the rotation would look like a 2D skew.
A hovering guard
The handler only wobbles while a hovering flag is true, set on pointerenter and cleared in reset() on pointerleave. This prevents stray moves (for example, during the spring-back transition) from re-triggering the wobble, so the return-to-rest animation always completes cleanly.
Decorative depth cues
A blurred white .wo-orb in the corner and a faint dotted texture (::after radial-gradient tiled at 18px) give the surface something to deform, so the parallax is visible. These sit at different effective depths from the text, reinforcing the soft-3D illusion as the card moves. The cursor: grab/grabbing states hint that the card is something you can push around.
Customizing it
Tune the 18/12/22 multipliers to control how far the shell leans, how much it tilts, and how strongly the content counter-moves — increasing the gap between the shell and inner factors makes the squish more pronounced. Adjust the cubic-bezier for a stiffer or bouncier spring, and recolor the gradient. Pair it with a glare card or a pin card for a set of tactile cards, or use it as an interactive feature cards tile.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA gradient card renders at rest.
- 2Drag your pointer across itThe card squishes and leans toward the cursor.
- 3Watch the contentThe text drifts the opposite way like soft parallax.
- 4Leave the cardEverything springs back on a bouncy easing.
- 5Adjust the squishChange the shell and inner translate multipliers.
- 6Tune the springEdit the cubic-bezier for stiffer or bouncier motion.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Two things: the shell and the inner content translate in opposite directions on pointer move, so the surface appears to stretch and the text resists, and both layers use a spring-like cubic-bezier(.2,.8,.2,1) that overshoots and settles. The opposing motion plus the bouncy easing read as soft, deformable material rather than a rigid tilt.
The pointermove handler leans the outer card toward the cursor while translating the inner content by a larger amount in the negative direction. Inside the perspective and preserve-3d context, the content's opposite slide reads as parallax depth within the card, which is what sells the squish.
It ensures only deliberate hovers wobble the card. The flag is set on pointerenter and cleared on pointerleave inside reset(), so stray pointer moves — for instance during the spring-back transition — don't re-trigger the effect and interrupt the return-to-rest animation.
No. The bounce comes entirely from the CSS cubic-bezier(.2,.8,.2,1) transition, an ease-out curve that slightly overshoots before settling. It approximates a spring closely enough for hover feedback without the cost or complexity of a real physics engine.
Render the card and inner content as a component, keep the perspective and preserve-3d CSS, and attach pointerenter, pointermove, and pointerleave handlers that write transforms via refs so they don't trigger re-renders. Keep the hovering flag in a ref. In Tailwind, use perspective and transform utilities with a custom spring easing defined via arbitrary transition-timing-function values.
Wobble Card — Export as HTML, React, Vue, Angular & Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wobble Card</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
.wo-stage{perspective:1200px}
.wo-card{position:relative;width:340px;height:260px;border-radius:22px;background:linear-gradient(135deg,#4f46e5,#9333ea);overflow:hidden;cursor:grab;transform-style:preserve-3d;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-card:active{cursor:grabbing}
.wo-inner{position:relative;z-index:1;height:100%;padding:26px;display:flex;flex-direction:column;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-tag{align-self:flex-start;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:rgba(255,255,255,.16);padding:5px 11px;border-radius:999px}
.wo-inner h3{margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em}
.wo-inner p{font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%}
.wo-orb{position:absolute;top:-40px;right:-40px;width:180px;height:180px;border-radius:50%;background:radial-gradient(circle at 30% 30%,rgba(255,255,255,.5),transparent 60%);filter:blur(6px)}
.wo-card::after{content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none}
</style>
</head>
<body>
<div class="wo-stage">
<article class="wo-card" id="woCard">
<div class="wo-inner" id="woInner">
<span class="wo-tag">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div class="wo-orb" aria-hidden="true"></div>
</div>
</article>
</div>
<script>
var card = document.getElementById('woCard');
var inner = document.getElementById('woInner');
var hovering = false;
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
card.addEventListener('pointerleave', reset);
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wobble Card</title>
<!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
* {
box-sizing:border-box;margin:0;padding:0
}
body {
font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
}
.wo-inner h3 {
margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em
}
.wo-inner p {
font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%
}
.wo-card::after {
content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none
}
</style>
</head>
<body>
<div class="[perspective:1200px]">
<article class="wo-card relative w-[340px] h-[260px] rounded-[22px] [background:linear-gradient(135deg,#4f46e5,#9333ea)] overflow-hidden cursor-grab [transform-style:preserve-3d] [transition:transform_.2s_cubic-bezier(.2,.8,.2,1)] active:cursor-grabbing" id="woCard">
<div class="wo-inner relative z-[1] h-full p-[26px] flex flex-col [transition:transform_.2s_cubic-bezier(.2,.8,.2,1)]" id="woInner">
<span class="self-start text-[11px] font-bold tracking-[.05em] uppercase bg-[rgba(255,255,255,.16)] py-[5px] px-[11px] rounded-[999px]">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div class="absolute -top-10 -right-10 w-[180px] h-[180px] rounded-full [background:radial-gradient(circle_at_30%_30%,rgba(255,255,255,.5),transparent_60%)] [filter:blur(6px)]" aria-hidden="true"></div>
</div>
</article>
</div>
<script>
var card = document.getElementById('woCard');
var inner = document.getElementById('woInner');
var hovering = false;
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
card.addEventListener('pointerleave', reset);
</script>
</body>
</html>import React, { useEffect } from 'react';
// CSS — optionally move to WobbleCard.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
.wo-stage{perspective:1200px}
.wo-card{position:relative;width:340px;height:260px;border-radius:22px;background:linear-gradient(135deg,#4f46e5,#9333ea);overflow:hidden;cursor:grab;transform-style:preserve-3d;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-card:active{cursor:grabbing}
.wo-inner{position:relative;z-index:1;height:100%;padding:26px;display:flex;flex-direction:column;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-tag{align-self:flex-start;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:rgba(255,255,255,.16);padding:5px 11px;border-radius:999px}
.wo-inner h3{margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em}
.wo-inner p{font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%}
.wo-orb{position:absolute;top:-40px;right:-40px;width:180px;height:180px;border-radius:50%;background:radial-gradient(circle at 30% 30%,rgba(255,255,255,.5),transparent 60%);filter:blur(6px)}
.wo-card::after{content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none}
`;
export default function WobbleCard() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var card = document.getElementById('woCard');
var inner = document.getElementById('woInner');
var hovering = false;
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
card.addEventListener('pointerleave', reset);
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
}, []);
return (
<>
<style>{css}</style>
<div className="wo-stage">
<article className="wo-card" id="woCard">
<div className="wo-inner" id="woInner">
<span className="wo-tag">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div className="wo-orb" aria-hidden="true"></div>
</div>
</article>
</div>
</>
);
}import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+
export default function WobbleCard() {
// Auto-generated escape hatch: the original snippet's vanilla JS runs once
// after mount and queries the rendered DOM. For idiomatic React, lift this
// into state + handlers.
useEffect(() => {
const _listeners = [];
const _originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
_listeners.push({ target: this, type, listener, options });
return _originalAddEventListener.call(this, type, listener, options);
};
var card = document.getElementById('woCard');
var inner = document.getElementById('woInner');
var hovering = false;
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
card.addEventListener('pointerleave', reset);
// Cleanup: restore addEventListener and remove all listeners
return () => {
EventTarget.prototype.addEventListener = _originalAddEventListener;
for (const { target, type, listener, options } of _listeners) {
target.removeEventListener(type, listener, options);
}
};
}, []);
return (
<>
<style>{`
* {
box-sizing:border-box;margin:0;padding:0
}
body {
font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
}
.wo-inner h3 {
margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em
}
.wo-inner p {
font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%
}
.wo-card::after {
content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none
}
`}</style>
<div className="[perspective:1200px]">
<article className="wo-card relative w-[340px] h-[260px] rounded-[22px] [background:linear-gradient(135deg,#4f46e5,#9333ea)] overflow-hidden cursor-grab [transform-style:preserve-3d] [transition:transform_.2s_cubic-bezier(.2,.8,.2,1)] active:cursor-grabbing" id="woCard">
<div className="wo-inner relative z-[1] h-full p-[26px] flex flex-col [transition:transform_.2s_cubic-bezier(.2,.8,.2,1)]" id="woInner">
<span className="self-start text-[11px] font-bold tracking-[.05em] uppercase bg-[rgba(255,255,255,.16)] py-[5px] px-[11px] rounded-[999px]">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div className="absolute -top-10 -right-10 w-[180px] h-[180px] rounded-full [background:radial-gradient(circle_at_30%_30%,rgba(255,255,255,.5),transparent_60%)] [filter:blur(6px)]" aria-hidden="true"></div>
</div>
</article>
</div>
</>
);
}<template>
<div class="wo-stage">
<article class="wo-card" id="woCard">
<div class="wo-inner" id="woInner">
<span class="wo-tag">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div class="wo-orb" aria-hidden="true"></div>
</div>
</article>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
let card;
let inner;
var hovering = false;
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
onMounted(() => {
card = document.getElementById('woCard');
inner = document.getElementById('woInner');
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
card.addEventListener('pointerleave', reset);
});
</script>
<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
.wo-stage{perspective:1200px}
.wo-card{position:relative;width:340px;height:260px;border-radius:22px;background:linear-gradient(135deg,#4f46e5,#9333ea);overflow:hidden;cursor:grab;transform-style:preserve-3d;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-card:active{cursor:grabbing}
.wo-inner{position:relative;z-index:1;height:100%;padding:26px;display:flex;flex-direction:column;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-tag{align-self:flex-start;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:rgba(255,255,255,.16);padding:5px 11px;border-radius:999px}
.wo-inner h3{margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em}
.wo-inner p{font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%}
.wo-orb{position:absolute;top:-40px;right:-40px;width:180px;height:180px;border-radius:50%;background:radial-gradient(circle at 30% 30%,rgba(255,255,255,.5),transparent 60%);filter:blur(6px)}
.wo-card::after{content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none}
</style>// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-wobble-card',
standalone: true,
imports: [CommonModule],
encapsulation: ViewEncapsulation.None,
template: `
<div class="wo-stage">
<article class="wo-card" id="woCard">
<div class="wo-inner" id="woInner">
<span class="wo-tag">Realtime</span>
<h3>Push, don't poll</h3>
<p>Drag your pointer across the card — it squishes and follows like soft jelly, then springs back.</p>
<div class="wo-orb" aria-hidden="true"></div>
</div>
</article>
</div>
`,
styles: [`
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a16;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
.wo-stage{perspective:1200px}
.wo-card{position:relative;width:340px;height:260px;border-radius:22px;background:linear-gradient(135deg,#4f46e5,#9333ea);overflow:hidden;cursor:grab;transform-style:preserve-3d;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-card:active{cursor:grabbing}
.wo-inner{position:relative;z-index:1;height:100%;padding:26px;display:flex;flex-direction:column;transition:transform .2s cubic-bezier(.2,.8,.2,1)}
.wo-tag{align-self:flex-start;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:rgba(255,255,255,.16);padding:5px 11px;border-radius:999px}
.wo-inner h3{margin-top:auto;font-size:26px;font-weight:900;letter-spacing:-.02em}
.wo-inner p{font-size:13.5px;color:rgba(255,255,255,.85);line-height:1.55;margin-top:8px;max-width:80%}
.wo-orb{position:absolute;top:-40px;right:-40px;width:180px;height:180px;border-radius:50%;background:radial-gradient(circle at 30% 30%,rgba(255,255,255,.5),transparent 60%);filter:blur(6px)}
.wo-card::after{content:'';position:absolute;inset:0;background-image:radial-gradient(rgba(255,255,255,.12) 1px,transparent 1px);background-size:18px 18px;opacity:.5;pointer-events:none}
`]
})
export class WobbleCardComponent implements AfterViewInit {
ngAfterViewInit(): void {
var card = document.getElementById('woCard');
var inner = document.getElementById('woInner');
var hovering = false;
card.addEventListener('pointerenter', function () { hovering = true; });
card.addEventListener('pointermove', function (e) {
if (!hovering) return;
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5; // -0.5..0.5
var py = (e.clientY - r.top) / r.height - 0.5;
// The shell tilts + translates toward the pointer (the "wobble"); the inner
// content lags in the opposite direction for a soft parallax squish.
card.style.transform =
'translate3d(' + (px * 18) + 'px,' + (py * 18) + 'px,0) ' +
'rotateX(' + (-py * 12) + 'deg) rotateY(' + (px * 12) + 'deg)';
inner.style.transform = 'translate3d(' + (-px * 22) + 'px,' + (-py * 22) + 'px,0)';
});
function reset() {
hovering = false;
card.style.transform = 'translate3d(0,0,0) rotateX(0) rotateY(0)';
inner.style.transform = 'translate3d(0,0,0)';
}
card.addEventListener('pointerleave', reset);
// Bind inline-handler functions to the component so the template can call them
Object.assign(this, { reset });
}
}