← Back to blog

Published on September 29, 2026

Mouse Hover Effects with CSS and JavaScript: Spotlight, 3D Card and Glare

  • css
  • javascript
  • web-development

In the first article of this series I rebuilt in pure CSS some backgrounds popularised by libraries like Aceternity UI. Here I do the same with effects that react to the mouse: a light following the cursor inside a card, a card tilting in 3D towards the pointer, a glare sweeping across its surface.

In the original library these effects are React components with external dependencies. According to their installation instructions, 3D Card Effect requires motion, clsx and tailwind-merge, while Card Spotlight also adds three and @react-three/fiber. The versions below use only CSS and a few lines of standard JavaScript, and they share the same pattern, which is worth understanding once.

The shared pattern: JavaScript measures, CSS draws

All three effects work like this:

  1. a pointermove listener receives the pointer position (clientX, clientY), relative to the window;
  2. getBoundingClientRect() gives the card's position, and the difference between the two is the pointer position inside the card;
  3. the value is written to a CSS variable with style.setProperty("--x", ...);
  4. the CSS uses that variable to place a gradient or compute a rotation.

The benefit of this split is that JavaScript never touches "visual" styles directly: it only passes numbers. Everything about how the effect looks (colours, radius, transitions, focus state) stays in CSS, where it's easy to change and can be switched off with a media query.

I use Pointer Events rather than the older mousemove, because they handle mouse, pen and touch with a single model.

One update per frame, not one per event

How often pointermove fires depends on the browser and the device, and updating the variables more often than the screen refreshes would be wasted work. So the code batches updates with requestAnimationFrame: on each event it cancels the previously scheduled frame and schedules a new one, so the real work happens at most once per frame, with the latest position available.

A note on metrics, to avoid a common misunderstanding: INP, the Core Web Vitals responsiveness metric, only observes clicks, taps and key presses. The web.dev page states that hovering and scrolling "are not observed for the purposes of INP". A heavy pointermove handler still ties up the main thread, though: if a click arrives at that moment, the response has to wait. Keeping the handler light matters anyway.

Spotlight: a light that follows the cursor

The spotlight is the simplest case: a ::before pseudo-element covers the card and holds a radial-gradient whose centre is var(--x) var(--y). When the pointer moves, the centre of the light follows it.

The gradient is invisible by default (opacity: 0) and appears on :hover. It also appears on :focus-visible: the card has tabindex="0", so keyboard users who reach it with Tab see the light switched on in the middle (the variables fall back to 50%). The effect isn't reserved for mouse users.

Demo

Spotlight following the pointer

Hover here

Code

index.html

<div class="spotlight" tabindex="0">…</div>

styles.css

.spotlight {
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

.spotlight::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: radial-gradient(220px circle at var(--x, 50%) var(--y, 50%), rgb(16 185 129 / 0.3), transparent 70%);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.spotlight:hover::before,
.spotlight:focus-visible::before {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .spotlight::before { transition: none; }
}

script.js

const card = document.querySelector(".spotlight");
let frame = 0;

card.addEventListener("pointermove", (e) => {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => {
    const rect = card.getBoundingClientRect();
    card.style.setProperty("--x", `${e.clientX - rect.left}px`);
    card.style.setProperty("--y", `${e.clientY - rect.top}px`);
  });
});

Note: the variable is updated even while the light is invisible, but that's cheap. What the browser has to redo on each move is repaint the gradient in its new position, work limited to the card's surface.

3D card: rotateX, rotateY and perspective

A card tilting towards the pointer looks complex, but it all comes down to the formula. Taking the pointer position normalised between 0 and 1 both horizontally (x) and vertically (y):

For the rotation to look three-dimensional you need perspective on the container: it sets the distance between the viewer and the plane, and therefore how pronounced the perspective effect is. Small values give strong distortion, large values a subtler one. The demo uses 800px.

Demo

3D card tilting towards the pointer

Move your mouse over the card

Code

index.html

<div class="tilt-wrap">
  <div class="tilt" tabindex="0">…</div>
</div>

styles.css

.tilt-wrap {
  perspective: 800px;
}

.tilt {
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transition: transform 0.15s ease-out;
  will-change: transform;
}

script.js

const card = document.querySelector(".tilt");
const MAX = 12;
const enabled = matchMedia(
  "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"
).matches;
let frame = 0;

if (enabled) {
  card.addEventListener("pointermove", (e) => {
    cancelAnimationFrame(frame);
    frame = requestAnimationFrame(() => {
      const r = card.getBoundingClientRect();
      const x = (e.clientX - r.left) / r.width;
      const y = (e.clientY - r.top) / r.height;
      card.style.setProperty("--rx", `${(0.5 - y) * 2 * MAX}deg`);
      card.style.setProperty("--ry", `${(x - 0.5) * 2 * MAX}deg`);
    });
  });

  card.addEventListener("pointerleave", () => {
    cancelAnimationFrame(frame);
    card.style.setProperty("--rx", "0deg");
    card.style.setProperty("--ry", "0deg");
  });
}

The effect uses only transform, one of the two properties web.dev's guide on high-performance animations recommends animating. A short transition (0.15s) smooths the jumps between positions; on pointerleave the variables go back to 0deg and the card straightens with the same transition.

Glare: a band of light moved with translate

The glare is a light diagonal band that sweeps across the card following the pointer, like light on a glossy surface. The temptation is to move it with background-position, but for the same reasons covered in the backgrounds article it's better to move the whole layer with transform.

The trick is making the pseudo-element three times as wide as the card (inset: 0 -100%), with the light band in the middle of the gradient. With translateX(0) the band sits in the middle of the card; shifting the layer by x - width / 2 pixels lines the centre of the band up with the pointer's x. The extra width guarantees the edge of the layer never enters the card.

To look like a real reflection rather than a white "bar", the band has no hard edges: the gradient ramps up gradually from 25% to 50% of its width and back down symmetrically to 75%, with several intermediate stops (8%, 30%, 70% and 100% of the chosen strength). The more stops, the softer the falloff.

Demo

Glare following the pointer

Hover here

Code

index.html

<div class="glare" tabindex="0">…</div>

styles.css

.glare {
  position: relative;
  overflow: hidden;
}

.glare::after {
  content: "";
  position: absolute;
  inset: 0 -100%;
  background: linear-gradient(
    105deg,
    transparent 25%,
    rgb(255 255 255 / 0.02) 33%,
    rgb(255 255 255 / 0.075) 41%,
    rgb(255 255 255 / 0.175) 47%,
    rgb(255 255 255 / 0.25) 50%,
    rgb(255 255 255 / 0.175) 53%,
    rgb(255 255 255 / 0.075) 59%,
    rgb(255 255 255 / 0.02) 67%,
    transparent 75%
  );
  transform: translateX(var(--gx, 0px));
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.glare:hover::after,
.glare:focus-visible::after {
  opacity: 1;
}

script.js

const card = document.querySelector(".glare");
let frame = 0;

card.addEventListener("pointermove", (e) => {
  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => {
    const r = card.getBoundingClientRect();
    card.style.setProperty("--gx", `${e.clientX - r.left - r.width / 2}px`);
  });
});

Glare and tilt combine well on the same card: just use a single listener that writes all the variables (--rx, --ry, --gx) in the same frame.

Touch, keyboard and reduced motion

A mouse-driven effect has to be designed for people who don't use a mouse, too.

Touch. The hover media feature tells you whether the primary input mechanism can hover over elements: according to MDN, hover: none identifies devices that can't hover, or can't do so conveniently, such as many phones. In the tilt code, the (hover: hover) and (pointer: fine) condition enables the effect only with a precise pointer. On touch the card stays still, and nothing is lost: it's decoration, the content is the same.

Keyboard. Spotlight and glare also appear on :focus-visible, as shown above. The tilt has no keyboard version, and that's fine: a tilted card conveys no extra information.

Reduced motion. WCAG 2.2 success criterion 2.3.3 Animation from Interactions (level AAA) requires that motion animation triggered by interaction can be disabled, unless it's essential. Its definition of "motion animation" excludes changes of colour, blurring and opacity that don't alter perceived size, shape or position: a light switching on falls under the exclusion, a rotating card doesn't. That's why in the demos, with prefers-reduced-motion: reduce enabled, the tilt isn't attached at all (the condition is in the same media query the JavaScript checks) and the light and glare transitions are removed.

Even if level AAA isn't always a project goal, here meeting it costs one media query.

Recap

In the third and final article of the series scroll becomes the engine of the animation: reveal, parallax and an infinite marquee, using CSS's new scroll-driven animations and a fallback for browsers that don't support them yet.