A grid of tool cards looked fine. It also looked dead. A wall of flat rectangles you scrolled past without ever touching.
So I gave the cards on ToolsTray two small hover effects: a spotlight that glows under the cursor, and a tilt that leans the card toward your pointer like a physical tile. Together they’re about forty lines and one pointer listener per card, and they’re most of the reason the homepage feels alive instead of static.
Here’s exactly how they work. And how I kept them from punishing anyone who can’t, or doesn’t want to, see them.
The spotlight is just two CSS variables
The glow is a radial gradient in CSS rather than an image or a canvas. It reads its position from two custom properties, --spot-x and --spot-y. JavaScript’s only job is to keep those two numbers in sync with the cursor:
const rect = el.getBoundingClientRect();
const px = pointer.clientX - rect.left;
const py = pointer.clientY - rect.top;
el.style.setProperty("--spot-x", `${px}px`);
el.style.setProperty("--spot-y", `${py}px`);
The card’s CSS does the rest with a radial-gradient(... at var(--spot-x) var(--spot-y) ...) that brightens on hover. Moving the actual paint into CSS keeps it on the compositor and keeps the JavaScript cheap.
The tilt is a rotation toward the cursor
Tilt maps the pointer’s position to a 3D rotation. The further the cursor sits from the centre, the more the card leans, capped so it never looks seasick.
const TILT_DEG = 6;
const rx = (0.5 - py / rect.height) * TILT_DEG * 2; // rotateX
const ry = (px / rect.width - 0.5) * TILT_DEG * 2; // rotateY
el.style.transform =
`perspective(600px) rotateX(${rx}deg) rotateY(${ry}deg) translateY(-3px)`;
Six degrees was the magic number. I started at twelve and it looked like a gimmick; at three you couldn’t tell it was on. The little translateY(-3px) lift sells the idea that the card has left the page.
Throttle to one update per frame
pointermove fires far more often than the screen refreshes, and setting transform on every event is how you get jank. So a move just stashes the latest event and asks for one animation frame; the frame does the maths once and clears the flag:
const onMove = (event) => {
last = event;
if (frame === 0) frame = requestAnimationFrame(apply);
};
No matter how fast you wave the mouse, each card updates at most once per repaint.
The bug that taught me something: don’t transition the transform
The first version smeared. On hover, the card’s resting transition: transform ... tried to animate the tilt toward every new cursor position, so it always lagged a few pixels behind. The card looked like it was wading through syrup.
The fix is to drop transform from the transition while the cursor is inside, so the tilt tracks instantly, then put a springy transition back on the way out so the card settles instead of snapping.
Gate it hard, or it’s an accessibility bug
This is the part I care about most. Cursor-following motion is delightful right up until it makes someone queasy, or fires on a touch tap where there’s no cursor at all. So the whole thing sits behind two media queries, checked once before anything is wired:
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const hover = matchMedia("(hover: hover)").matches;
if (!reduce && hover) { /* wire the cards */ }
Ask your OS for reduced motion, or use a touchscreen with no real pointer, and none of it runs. The cards stay perfectly usable. They just don’t dance. It’s opt-in per element, too: a card requests what it wants with data-tilt and data-spotlight attributes, so I can be playful on the homepage and calm on a tool page.
Would I add this to every site? No.
It’s candy. On a blog or a checkout flow I’d leave it out, because there it’s motion for motion’s sake. But on a tools homepage where the cards are the product, half a second of delight as you mouse across them earns its forty lines. And gating it means it costs the people who don’t want it exactly nothing.
You can feel it for yourself. The cards are all over the ToolsTray homepage and the category pages, and they lead to things like the PDF compressor and the background remover. Wave your cursor across a few.



