CreaTech
LAB-002React

Cursor-following spotlight on a frosted glass card

A soft accent glow that follows the pointer across a frosted panel, built from one pseudo-element and two custom properties.

Built 4 Aug 2026 · Last verified 4 Aug 2026

The short version

A spotlight card renders a radial gradient positioned at the pointer, layered under the card's content. The pointer position is written into --mx and --my as percentages of the card's own box, and a ::after pseudo-element reads them to place the gradient. It costs one event listener per card and no re-renders, because the values go straight to the element's style rather than through React state.

LAB-002live

How it works

  1. The card gets position: relative and isolation: isolate, so the glow can be absolutely positioned inside it without escaping into the page.
  2. A ::after pseudo-element covers the card, inherits its border-radius, and is given pointer-events: none so it never intercepts clicks.
  3. Its background is a radial-gradient whose centre is at var(--mx) var(--my), defaulting to 50% 50% so the card looks correct before the pointer ever arrives.
  4. An onMouseMove handler converts the cursor position into percentages of the card's bounding box and writes them with element.style.setProperty. No state, no re-render, no layout read beyond one getBoundingClientRect.
  5. The gradient's colour is var(--accent-soft), so each card inherits its own pastel from the same accent variable that themes the rest of the site.
  6. Opacity transitions from 0 to 1 on hover rather than the gradient appearing instantly, which stops the glow from snapping into existence when the pointer crosses the edge.

This one is on the site you are reading

Every project card on the CreaTech homepage does this, and so does the card that brought you to this page. It is the effect I have got the most mileage out of, partly because it costs almost nothing and partly because it solves a real problem with dark interfaces.

Dark cards on a dark ground have a flatness problem. Borders alone are not enough separation, and adding a lighter fill to each card either kills the frosted-glass look or turns the page into a grid of grey boxes. A light source that follows the cursor gives you separation on demand: the card you are looking at is lit, and the rest stay quiet.

Why the pseudo-element, and not a div

The glow is a ::after on the card rather than an element in the markup. Three reasons, in order of how much they matter.

It inherits border-radius. A separate div would need the radius restated, and would then need it restated again the day the card's radius changes. The pseudo-element gets it for free with border-radius: inherit.

It never reaches the accessibility tree. There is no empty div for a screen reader to walk past, and nothing to have to remember to mark aria-hidden.

And it cannot be styled by accident. A <div class="glow"> inside a card is a target for every descendant selector anyone writes later. A pseudo-element is addressable only from the card itself.

The three lines that make it behave

Three declarations do the work that is easy to get wrong:

isolation: isolate on the card creates a stacking context, so the absolutely positioned glow stays inside the card instead of escaping into the page. Without it you get a glow floating over the card's neighbours the first time something above it creates its own stacking context.

pointer-events: none on the glow. Obvious in hindsight, invisible until the day a card stops being clickable and you cannot see why.

And a default of 50% 50% for the position. The card has to look correct before the pointer has ever been over it, which is the state it is in when the page first paints and the state it stays in on a touch device. A missing default gives you a glow stuck in the top-left corner for every visitor on a phone.

What the fade is for

The opacity transition is not decoration. Without it, moving the cursor across the boundary between two cards makes the glow blink from one card to the other, and the blink is what you notice rather than the light.

Fading over roughly 450ms means the outgoing card is still dimming while the incoming one lights, which reads as a light source moving across a surface rather than two lamps switching. It is the difference between the effect looking designed and looking implemented.

Cost

One getBoundingClientRect per pointer event, which is a layout read, and two setProperty calls, which are not. No React re-render. The gradient itself is composited, so moving it does not repaint the card's content.

On a page with a dozen of these, that is a dozen idle event listeners and one active handler at a time. It has never shown up in a profile for me, and I have looked, because a page whose whole argument is that it is fast should be able to account for what it spends.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import { type MouseEvent } from "react";
import styles from "./Demo.module.scss";

/**
 * Writes the pointer position, as a percentage of the element's own box, into
 * two custom properties. Straight to the element's style: no state, so React
 * never re-renders while the cursor moves.
 */
function trackSpot(e: MouseEvent<HTMLElement>) {
  const el = e.currentTarget;
  const r = el.getBoundingClientRect();
  el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
  el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
}

export default function SpotlightGlassCard() {
  return (
    <div className={styles.wrap}>
      <article className={styles.card} onMouseMove={trackSpot}>
        <div className={styles.body}>
          <span className={styles.eyebrow}>Frosted panel</span>
          <h3 className={styles.title}>Move the cursor across me</h3>
          <p className={styles.blurb}>
            The glow is one pseudo-element reading two custom properties. It
            sits under this text, inherits the card&rsquo;s radius, and never
            intercepts a click.
          </p>
        </div>
      </article>
    </div>
  );
}
Demo.module.scssscss
.wrap {
  display: grid;
  place-items: center;
  padding: clamp(24px, 5vw, 44px);
}

.card {
  /* isolation keeps the glow's stacking context inside the card, so it can be
     absolutely positioned without escaping into the page. */
  position: relative;
  isolation: isolate;
  overflow: hidden;

  width: min(100%, 380px);
  padding: 28px;
  border-radius: var(--r-card);
  border: 1px solid var(--line);
  background: var(--glass);
  backdrop-filter: blur(18px) saturate(140%);
  box-shadow: var(--shadow-2);
}

/* The glow. Defaults to dead centre so the card looks right before the
   pointer has ever been over it. */
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.45s ease;
  background: radial-gradient(
    240px 240px at var(--mx, 50%) var(--my, 50%),
    var(--accent-soft, rgba(255, 255, 255, 0.09)),
    transparent 66%
  );
}

.card:hover::after {
  opacity: 1;
}

.body {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.eyebrow {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--accent, var(--silver-300));
}

.title {
  font-size: 20px;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.25;
}

.blurb {
  font-size: 14px;
  line-height: 1.6;
  color: var(--silver-300);
}

/* In the homepage strip there is no room for the blurb, and half a sentence
   clipped by an overflow is worse than none. */
@container stage (max-width: 360px) {
  .card {
    padding: 20px;
  }
  .blurb {
    display: none;
  }
}

Questions worth answering

Does it work in Safari?

Every current browser. Radial gradients on custom-property positions have been safe for years. The frosted backdrop-filter behind it is the only part with any history, and it degrades to a flat translucent panel where it is unsupported.

Does it need JavaScript?

One handler, no state. The pointer position goes straight to the element's style, so React never re-renders while you move the cursor. That distinction is the whole performance story of this one.

Is it accessible?

Reduced motion. The glow's fade is a transition, so the site-wide reduced-motion rule already reduces it to an instant state change. Nothing moves that a visitor has to track.

Keyboard. The glow is decorative and pointer-only. The card itself is a link with a visible focus ring, so keyboard users get the same navigation without a cursor-following effect that would have no cursor to follow.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.