---
name: spotlight-card
description: Use when you want "Premium, interactive, modern" - Adds a cursor-following glow inside or around a card.
---

# Spotlight Card

> **Category:** Card / Layout  -  **Personality:** Premium, interactive, modern
>
> **Best use:** SaaS cards, AI interfaces
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A dark card (or a whole grid of them) that lights up under the cursor: a soft radial glow follows the pointer **inside** the card, and the thin border brightens on the edge nearest the cursor. One `pointermove` handler writes the pointer's position into CSS custom properties; CSS does all the painting. Because every card reads the *same* pointer against its *own* box, the highlight reads as a single spotlight sweeping across the grid — the signature "Linear / Vercel" feature-section look that feels premium and alive without any library.

## Dependencies / CDN

None - pure CSS + vanilla JS. The demo only loads display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

A grid wrapper plus any number of cards. The cards carry no inline state — JS fills the CSS vars at runtime:

```html
<div class="grid">
  <article class="card">
    <div class="inner">
      <h3>Semantic code search</h3>
      <p>Ask in plain English and land on the exact function.</p>
      <span class="meta">6 repos indexed</span>
    </div>
  </article>
  <!-- repeat .card … -->
</div>
```

## CSS

```css
.card{
  --mx:50%; --my:0%;             /* pointer pos: px from JS, % fallback   */
  --glow:340px;                  /* spotlight radius                      */
  --edge:#9d8cff;                /* border-light colour                   */
  --fill:rgba(141,124,255,.16);  /* interior glow colour                  */
  position:relative; border-radius:18px; border:0; overflow:hidden;
  padding:23px 22px; background:linear-gradient(180deg,#111219,#0c0d13);
}

/* 1px RIM = a constant faint ring + a glow that tracks the cursor.
   The mask keeps only the 1px border-shaped sliver of the gradients. */
.card::before{
  content:""; position:absolute; inset:0; z-index:2; border-radius:inherit;
  padding:1px; pointer-events:none;
  background:
    radial-gradient(var(--glow) circle at var(--mx) var(--my), var(--edge), transparent 45%),
    linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.04));
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
}

/* INTERIOR glow, revealed only on the card the cursor is over */
.card::after{
  content:""; position:absolute; inset:0; z-index:0; border-radius:inherit; pointer-events:none;
  background:radial-gradient(270px circle at var(--mx) var(--my), var(--fill), transparent 56%);
  opacity:0; transition:opacity .35s ease;
}
.card:hover::after{opacity:1}

.inner{position:relative; z-index:1}   /* content above the interior glow */

/* keep a tasteful static glow when motion is reduced (JS bails out) */
@media (prefers-reduced-motion:reduce){ .card::after{opacity:.5} }
```

## JavaScript

```js
const grid = document.querySelector('.grid');
const cards = grid.querySelectorAll('.card');

// Cursor-following motion: opt out under reduced-motion (CSS keeps a static glow)
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
  let ex = 0, ey = 0, queued = false;

  function apply(){
    queued = false;
    for (const card of cards){
      const r = card.getBoundingClientRect();
      card.style.setProperty('--mx', (ex - r.left) + 'px');
      card.style.setProperty('--my', (ey - r.top)  + 'px');
    }
  }

  grid.addEventListener('pointermove', e => {
    ex = e.clientX; ey = e.clientY;            // store latest position…
    if (!queued){ queued = true; requestAnimationFrame(apply); }  // …flush once per frame
  }, { passive:true });
}
```

## How it works

- **Two gradients positioned at one moving point.** Both `::before` (rim) and `::after` (fill) draw `radial-gradient(... circle at var(--mx) var(--my) ...)`. JS only updates `--mx`/`--my`; the browser re-paints the gradients — no layout, no per-frame DOM churn.
- **The border trick.** A pseudo-element at `inset:0` with `padding:1px` is masked with two layers composited as `exclude` (`xor`), leaving only the 1px border-shaped sliver painted. The card itself has `border:0`, so its padding-box equals its border-box and the sliver lands exactly on the visual edge.
- **One pointer, many boxes.** Each card subtracts its *own* `getBoundingClientRect()` from the shared cursor coordinate. A card far from the pointer gets a gradient origin far outside itself, so only the faded tail (if any) reaches its edge — which is why the highlight glides continuously from card to card.
- **rAF coalescing.** `pointermove` fires faster than the display refreshes; storing the latest x/y and flushing once per `requestAnimationFrame` caps the work at one update per frame.

## Customization

- **Reach / softness:** grow `--glow` and the gradient's `transparent NN%` stop for a wider, softer pool; shrink them for a tight pinspot.
- **Colour:** set `--edge` (rim) and `--fill` (interior) per card — the demo's "featured" card swaps both to cyan to stand out. Use your brand accent.
- **Border weight:** change the `padding` on `::before` (1px → 1.5px) for a chunkier lit edge.
- **Ambient layer:** for extra depth, add a big `radial-gradient` on the *container* driven by a second pair of vars (`--sx/--sy`) so a soft halo trails the cursor behind the whole grid.
- **Interior on hover only vs. always:** the demo fades `::after` in on `:hover`; drop the `opacity` gate to keep every card's interior lit at once.

## Accessibility & performance

- **Only custom properties change**, so the browser repaints gradients without reflow; with rAF throttling a 6-card grid is effectively free (rated 2/5).
- **Respect `prefers-reduced-motion`:** the JS returns early so nothing tracks the cursor; CSS leaves a gentle static glow so the cards still look designed.
- **Touch / keyboard:** the glow is pure decoration — never put information *only* in the glow. There is no pointer on touch, so the static fallback is what mobile users see; that's fine.
- **Contrast:** keep body text on the solid card background, not on the glow, so contrast never depends on cursor position.

## Gotchas

- **Vendor-prefix the mask.** Safari still needs `-webkit-mask` *and* `-webkit-mask-composite:xor`; the standard `mask-composite:exclude` covers Chrome/Firefox. Ship both or the rim fills solid.
- **The card must not have a real `border`.** With `border:0`, abs-positioned `inset:0` covers the full box; add a border and the `::before` ring insets to the padding-box and the glow no longer sits on the visual edge.
- **`overflow:hidden` + `border-radius`** on the card clips the interior glow to the rounded corners — keep it, or the radial bleeds past the rounded edge.
- **Read rects inside the rAF, not on every event** — and re-read them each frame: the page can scroll between moves, so cached rects drift. For huge grids, throttle further or only update cards within the viewport.
- **`pointermove` (not `mousemove`)** unifies mouse + pen and pairs with `{passive:true}` to avoid scroll-jank warnings.
