---
name: spotlight-image-hover
description: Use when you want "Premium, interactive, focused" - Reveals or brightens an area around the cursor on an image.
---

# Spotlight Image Hover

> **Category:** Image / Media  -  **Personality:** Premium, interactive, focused
>
> **Best use:** Product images, galleries
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

The image sits in the dark — desaturated and dimmed — and the cursor becomes a beam of light: wherever the pointer goes, a small pool of the photograph snaps back to full, bright colour. It's built from **two stacked copies of the same image**, where the top (colour) copy is shown through a `radial-gradient` **mask** centred on a CSS variable that JS updates on `pointermove`. Reach for it on product shots and gallery walls where you want the viewer to actively *discover* the image rather than be handed it — it reads as premium, focused and tactile. (This is distinct from a "Spotlight Card", which lights a card's *border/background* rather than recolouring an image.)

## Dependencies / CDN

None - pure CSS + a few lines of vanilla JS. The demo only loads display/label/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&family=IBM+Plex+Mono:wght@400;500&family=Instrument+Serif&display=swap" rel="stylesheet">
```

## HTML

Two identical `<img>`s in a positioned frame — the second is the colour reveal layer, plus an optional warm halo:

```html
<figure class="sp-frame is-lit">
  <img class="sp-img sp-dim" src="/img/portrait.jpg" alt="After the Rain — Selene Marchetti">
  <img class="sp-img sp-lit" src="/img/portrait.jpg" alt="" aria-hidden="true">
  <span class="sp-glow" aria-hidden="true"></span>
</figure>
```

## CSS

```css
/* Register the radius so it can ease on/off and grow when "flooding". */
@property --sp-r{ syntax:"<length>"; inherits:true; initial-value:0px; }

.sp-frame{
  position:relative; aspect-ratio:3/2; overflow:hidden; border-radius:16px; background:#000;
  cursor:crosshair;
  --sp-x:50%; --sp-y:50%; --sp-r:0px;              /* pointer x/y + spotlight radius */
  transition:--sp-r .5s cubic-bezier(.22,.61,.36,1);
}
.sp-frame.is-lit  { --sp-r:clamp(88px,14vw,138px); } /* a resting / hovered pool of light */
.sp-frame.is-flood{ --sp-r:1500px; }                 /* reveal the whole image            */

.sp-img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }

/* base layer: the dark room */
.sp-dim{ z-index:1; filter:grayscale(.92) brightness(.32) contrast(1.06); }

/* reveal layer: full colour, visible ONLY inside the radial mask at the pointer */
.sp-lit{
  z-index:2; filter:saturate(1.14) contrast(1.04) brightness(1.06);
  -webkit-mask-image:radial-gradient(circle var(--sp-r) at var(--sp-x) var(--sp-y),
            #000 0%, #000 58%, rgba(0,0,0,.4) 82%, transparent 100%);
          mask-image:radial-gradient(circle var(--sp-r) at var(--sp-x) var(--sp-y),
            #000 0%, #000 58%, rgba(0,0,0,.4) 82%, transparent 100%);
}

/* optional warm tungsten halo riding the beam */
.sp-glow{
  position:absolute; z-index:3; left:var(--sp-x); top:var(--sp-y);
  width:calc(var(--sp-r) * 2.3); height:calc(var(--sp-r) * 2.3); transform:translate(-50%,-50%);
  border-radius:50%; pointer-events:none; mix-blend-mode:screen; opacity:0; transition:opacity .45s;
  background:radial-gradient(circle,rgba(255,206,140,.18) 0%,rgba(255,190,120,.06) 46%,transparent 70%);
}
.sp-frame.is-lit .sp-glow{ opacity:1; }
.sp-frame.is-flood .sp-glow{ opacity:0; }
```

## JavaScript

Write the pointer position into two CSS vars; the mask does the rest (no animation loop):

```js
var frame = document.querySelector('.sp-frame');
function track(e){
  var r = frame.getBoundingClientRect();
  frame.style.setProperty('--sp-x', (e.clientX - r.left) + 'px');
  frame.style.setProperty('--sp-y', (e.clientY - r.top)  + 'px');
}
frame.addEventListener('pointermove', track);
frame.addEventListener('pointerdown', track);                 // touch: light where you press
frame.addEventListener('pointerleave', function(){            // rest the beam at centre
  frame.style.setProperty('--sp-x','50%');
  frame.style.setProperty('--sp-y','50%');
});

// Optional "flood the room" toggle:
document.querySelector('[data-sp-flood]')?.addEventListener('click', function(){
  frame.classList.toggle('is-flood');
});
```

## How it works

- **Two layers, one masked.** The bottom `<img>` is permanently grayscale + dark. The identical top `<img>` is full colour but its `mask-image` is a `radial-gradient` whose painted (opaque) core is a small circle — so only that circle of colour shows; everywhere else the mask is `transparent` and you see the dim layer beneath.
- **The mask follows the pointer** because its centre is `at var(--sp-x) var(--sp-y)`, and JS rewrites those two variables on every `pointermove`. No repaint loop, no `requestAnimationFrame` — the compositor just re-evaluates the gradient.
- **The radius is a registered `@property`** (`--sp-r`, `<length>`), so it can be *transitioned*: 0 → a pool when lit, or → 1500px to "flood" the whole frame. Inheriting it (`inherits:true`) lets the halo size off the same value.
- **The soft edge** (`#000 58% → transparent 100%`) feathers the pool so the light falls off naturally instead of a hard cookie-cutter disc.

## Customization

- **Pool size:** change `.is-lit{--sp-r}` (tighter = more "torch", wider = "soft lamp").
- **How dark the room is:** tune `.sp-dim` `brightness()` / `grayscale()`. Lower brightness = more dramatic reveal; drop `grayscale` if you only want a *brightness* spotlight, not a colour one.
- **Edge softness:** move the middle stops (`58%`, `82%`) — closer together = crisper rim.
- **Light colour:** recolour `.sp-glow` (warm tungsten here; try a cool `rgba(150,190,255,…)` for a "scanner" feel) or drop the halo entirely.
- **No-image variant:** the exact same mask trick works to spotlight a block of text or a `<canvas>` — just swap the two image layers for any two stacked elements.

## Accessibility & performance

- **Cheap.** One `mask-image` on a static image, updated via a CSS var — GPU-friendly (Performance 2/5). No JS timer runs; events fire only while the pointer moves.
- **Reduced motion:** there's no looping animation, so the effect itself is fine to leave on. The only motion is the radius easing on/flood, which the catalog stylesheet's `@media (prefers-reduced-motion: reduce)` already collapses to ~0ms; in your own page add the same guard so the pool simply appears.
- **Keep the alt text on the *dim* layer** (the one users perceive); the colour duplicate is `alt="" aria-hidden="true"` so screen readers don't announce the image twice.
- **Touch:** `pointerdown` lights the press point so the effect still demos on phones; it never traps scrolling.
- **Contrast:** any caption sitting over the (mostly dark) image needs its own backing (the demo uses a small frosted plaque) — don't rely on the spotlight to make overlaid text legible.

## Gotchas

- **Always ship `-webkit-mask-image`** alongside `mask-image`, or the reveal silently fails in Safari/iOS.
- **`circle <length> at <x> <y>` needs an explicit unit.** Feed `--sp-x/--sp-y` as `px` (or `%`); a bare number is invalid and the mask won't render.
- **`@property` is the smoothness, not the function.** If a browser ignores `@property`, `--sp-r` still works as a plain variable — the spotlight just snaps on/off instead of easing. Don't rely on the transition for the effect to *exist*.
- **Both images must be pixel-identical** (same `src`, same `object-fit`/size) or the colour pool won't line up with what's underneath.
- **The parent needs `overflow:hidden`** so the mask/halo are clipped to the frame, and the colour layer must sit *above* the dim layer (`z-index`) — reversed, you'd spotlight grey onto colour.
