How it works

One light, many cards

It looks like a single spotlight shining through a cut-out grid. In fact every card paints its own gradient, and they all agree on where the light is because they share two custom properties.

The idea

Each card has two pseudo-elements with the same radial-gradient as background: one masked down to a thin ring that sits exactly on the border, one behind the content at low opacity. If every card centers that gradient on the same point of the page, the light flows across gaps and from card to card as if it were one. The work is in getting that shared point into every card cheaply.

1. One pointer listener for the whole grid

Instead of a listener per card, the grid has a single pointermove handler. It remembers the latest event and, at most once per frame (requestAnimationFrame), writes the pointer position relative to the grid's top left corner. Pointer events cover mouse, pen and touch. On touch, a tap or press lights up the spot under your finger; once a drag turns into a page scroll, the browser takes the pointer over (pointercancel) and the light fades out. That keeps the page scrollable over the grid; the price is that the light doesn't follow a swiping finger. pointerleave turns it off as well.

2. Two custom properties, inherited by every card

The handler writes --spotlight-x and --spotlight-y (in px) on the grid element. Custom properties inherit, so every card can read them without any JavaScript touching the cards. A third property, --spotlight-on, is 1 while the pointer is over the grid and drives the opacity of the glow (with a short fade).

3. Per-card coordinates, measured once

A gradient's position is relative to the box it paints, so each card needs the pointer in its own coordinates. Two ways to get there:

Write per card
On every move, loop over the cards, measure each one and set local coordinates. Simple, but the work grows with the number of cards.
Fixed attachment
Give every card background-attachment: fixed with a gradient in viewport coordinates; then a single viewport-relative position works for all. Elegant, but fixed backgrounds are ignored on iOS Safari, break inside transformed ancestors, and don't apply to masked pseudo-elements reliably.

This page uses a third, mixed approach. Each card's offset inside the grid is written to the card as --spotlight-card-x/y only when the layout changes: on load, when a ResizeObserver reports a size change of the grid or a card, when a MutationObserver sees cards added or removed (which are then observed too), when the gap changes, and when the pointer enters the grid (cheap insurance against reflows nobody announced). The card then computes its local position in CSS: calc(var(--spotlight-x) - var(--spotlight-card-x)). A pointer move writes two properties, whatever the number of cards.

The vanilla version below uses the same property names as the component; it leaves out the MutationObserver for cards added later.

const grid = document.querySelector('.grid');
let pending, raf = 0;

// once per layout change, not per pointer move
function measure() {
  const g = grid.getBoundingClientRect();
  for (const card of grid.querySelectorAll('.card')) {
    const r = card.getBoundingClientRect();
    card.style.setProperty('--spotlight-card-x', `${r.left - g.left}px`);
    card.style.setProperty('--spotlight-card-y', `${r.top - g.top}px`);
  }
}
const resize = new ResizeObserver(measure);
resize.observe(grid);
for (const card of grid.querySelectorAll('.card')) resize.observe(card);

function move(e) {
  pending = e;
  grid.style.setProperty('--spotlight-on', 1);
  raf ||= requestAnimationFrame(() => {
    raf = 0;
    const g = grid.getBoundingClientRect();
    grid.style.setProperty('--spotlight-x', `${pending.clientX - g.left}px`);
    grid.style.setProperty('--spotlight-y', `${pending.clientY - g.top}px`);
  });
}
const off = () => grid.style.setProperty('--spotlight-on', 0);

// re-measure on entry: cheap insurance against reflows nobody announced
grid.addEventListener('pointerenter', (e) => { measure(); move(e); });
grid.addEventListener('pointermove', move);
grid.addEventListener('pointerleave', off);
grid.addEventListener('pointercancel', off);

4. The light: radial-gradient

radial-gradient(radius circle at var(--x) var(--y), color, second color 45%, transparent) draws a circle of light around the pointer. Because --x can be far outside the card (or negative), only the matching slice of the circle shows up in each card. With Two-tone the color changes from the center outwards, which reads as a hot core and a colored halo; without it, the color fades out on its own.

5. The border ring: mask-composite: exclude

The ::before pseudo-element covers the card's border box and gets padding equal to the border width. Its mask has two layers: one covering the whole box and one restricted to the content-box, combined with exclude (keep what only one of them covers). What survives is the padding, a ring that follows the border-radius exactly. The same technique draws the rim on the frosted glass. Underneath, the card's real border stays visible as a dim outline, which is also the resting state without a pointer.

The ::after pseudo-element paints the same gradient over the whole face at low opacity, with z-index: -1 inside the card (which has isolation: isolate), so it sits above the card background and below the text.

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
  gap: 16px;
}

.card {
  /* the light in this card's coordinates */
  --x: calc(var(--spotlight-x) - var(--spotlight-card-x));
  --y: calc(var(--spotlight-y) - var(--spotlight-card-y));
  --on: var(--spotlight-on, 0);
  position: relative;
  isolation: isolate;
  border: 1.5px solid rgb(255 255 255 / 0.14);
  border-radius: 16px;
  background: #15131f;
}

.card:focus-visible,
.card:has(:focus-visible) {
  --x: 50%;
  --y: 50%;
  --on: 1;
}

.card::before,
.card::after {
  content: '';
  position: absolute;
  inset: -1.5px; /* the border box */
  border-radius: inherit;
  background: radial-gradient(280px circle at var(--x) var(--y), #a78bfa, #22d3ee 45%, transparent 100%);
  pointer-events: none;
  transition: opacity 0.4s;
}

/* border ring */
.card::before {
  padding: 1.5px;
  mask: linear-gradient(#000 0 0) content-box exclude,
        linear-gradient(#000 0 0);
  opacity: calc(var(--on) * 1);
}

/* face, behind the content */
.card::after {
  z-index: -1;
  opacity: calc(var(--on) * 0.12);
}

6. Keyboard focus and contrast

The cards are links. :focus-visible (and :has(:focus-visible) for cards that only contain a link) overrides the card's local position with 50% 50% and switches its glow on, so keyboard users see the effect on the card they are on, plus the regular focus outline.

Colored light behind text eats contrast. The face glow is strongest at its color stops: the first color at the center and, with Two-tone, the second at 45% of the radius. With the default colors, the cyan ring is actually brighter than the violet center. At each stop it is simply that color at the chosen opacity on top of the dark card, so the grid computes that mixture for every color and caps the opacity at the highest value where the dimmest text (the descriptions) still has 4.5:1. For the default colors that cap is far above the default; bright colors like white or yellow lower it, and the panel tells you when it kicks in.

Browser support and cost

Custom properties, radial-gradient, mask-composite via the mask shorthand and :focus-visible work in all current browsers; :has() is newer (Firefox 121, Safari 15.4) and only matters for cards that contain a link instead of being one. @property registers --spotlight-x/y as lengths so they can be animated; without it the glow still works. Tested in Chromium only; Firefox and Safari weren't checked.

Cost: each move repaints every card's two pseudo-elements, since their gradient position changed. That's a cheap gradient fill per card, fine for dozens of cards. For very large grids, limit the effect to cards near the pointer, for example by only giving visible cards the pseudo-elements.

Back to the demo ↑