25 CSS Background Animations02 / 25

Light JSMIT licensed

Cursor Spotlight Follow Effect

A torch dragged across a dark surface. Two lines of pointer JavaScript write --mx / --my; everything else — the light falloff, the mask that reveals a hidden colour layer underneath, the grid that brightens only inside the beam — is CSS. It idles on its own orbit until you move, so it is never a dead rectangle on touch devices, and it hard-stops for reduced-motion users.

Published

Live Demo
Try it

The code

<section class="bga-02" id="bga-02-root" aria-label="Cursor spotlight follow background demo">
  <div class="bga-02__grid" aria-hidden="true"></div>
  <div class="bga-02__reveal" aria-hidden="true">
    <img class="bga-02__photo" src="https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1800&auto=format&fit=crop" alt="" width="1800" height="1200" loading="lazy" decoding="async">
  </div>
  <div class="bga-02__beam" aria-hidden="true"></div>
  <div class="bga-02__vig" aria-hidden="true"></div>

  <div class="bga-02__stage">
    <div class="bga-02__panel">
      <p class="bga-02__eyebrow">Pointer-reactive · 02</p>
      <h1 class="bga-02__title">Move the light.<br>The surface answers.</h1>
      <p class="bga-02__sub">Two custom properties are written on pointermove. The falloff, the mask, the grid glow and the easing are all CSS — the main thread does almost nothing.</p>
      <ul class="bga-02__meta">
        <li><b>0</b><span>rAF loops</span></li>
        <li><b>2</b><span>properties written</span></li>
        <li><b>1</b><span>passive listener</span></li>
      </ul>
    </div>
    <p class="bga-02__hint">Move your cursor — or wait, and it orbits on its own.</p>
  </div>
</section>
@property --bga-02-mx {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 50%;
}

@property --bga-02-my {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 44%;
}

.bga-02,
.bga-02 *,
.bga-02 *::before,
.bga-02 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bga-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  overflow: hidden;
  isolation: isolate;
  --bga-02-bg: oklch(0.16 0.024 265);
  --bga-02-acc: oklch(0.78 0.16 232);
  --bga-02-acc2: oklch(0.7 0.19 320);
  --bga-02-r: clamp(240px,32vw,460px);
  --bga-02-mx: 50%;
  --bga-02-my: 44%;
  background: var(--bga-02-bg);
  color: oklch(0.97 0.01 250);
  font-family: ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  -webkit-font-smoothing: antialiased;
  transition: --bga-02-mx .45s cubic-bezier(.16,1,.3,1),--bga-02-my .45s cubic-bezier(.16,1,.3,1);
}

.bga-02.is-idle {
  animation: bga-02-orbit 16s ease-in-out infinite;
}

@keyframes bga-02-orbit {
  0% {
    --bga-02-mx: 26%;
    --bga-02-my: 32%;
  }

  25% {
    --bga-02-mx: 74%;
    --bga-02-my: 26%;
  }

  50% {
    --bga-02-mx: 80%;
    --bga-02-my: 74%;
  }

  75% {
    --bga-02-mx: 22%;
    --bga-02-my: 70%;
  }

  100% {
    --bga-02-mx: 26%;
    --bga-02-my: 32%;
  }
}

.bga-02__grid {
  position: absolute;
  inset: 0;
  opacity: .5;
  background-image: linear-gradient(oklch(1 0 0/.07) 1px,transparent 1px),linear-gradient(90deg,oklch(1 0 0/.07) 1px,transparent 1px);
  background-size: 56px 56px;
}

.bga-02__reveal {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg,color-mix(in oklab,var(--bga-02-acc) 30%,transparent),color-mix(in oklab,var(--bga-02-acc2) 34%,transparent)), linear-gradient(oklch(1 0 0/.3) 1px,transparent 1px), linear-gradient(90deg,oklch(1 0 0/.3) 1px,transparent 1px);
  background-size: 100% 100%,56px 56px,56px 56px;
  -webkit-mask-image: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),oklch(0 0 0) 0 38%,transparent 74%);
  mask-image: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),oklch(0 0 0) 0 38%,transparent 74%);
}

.bga-02__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: .62;
  mix-blend-mode: luminosity;
}

.bga-02__beam {
  position: absolute;
  inset: 0;
  mix-blend-mode: screen;
  background: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),
      color-mix(in oklab,var(--bga-02-acc) 46%,transparent) 0%,
      color-mix(in oklab,var(--bga-02-acc) 14%,transparent) 42%,transparent 70%);
}

.bga-02__vig {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(130% 110% at 50% 40%,transparent 40%,oklch(0.08 0.02 265/.85) 100%);
}

.bga-02__stage {
  position: relative;
  z-index: 3;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(22px,5vw,68px);
  pointer-events: none;
}

.bga-02__panel {
  width: min(100%,700px);
  display: grid;
  gap: 16px;
  padding: clamp(24px,3.4vw,44px);
  border-radius: 24px;
  text-align: center;
  justify-items: center;
  background: oklch(0.14 0.02 265/.55);
  border: 1px solid oklch(1 0 0/.12);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow: 0 40px 90px -40px oklch(0 0 0/.9);
}

.bga-02__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--bga-02-acc);
}

.bga-02__title {
  font-size: clamp(28px,5.6vw,58px);
  font-weight: 640;
  line-height: 1.05;
  letter-spacing: -.045em;
  text-wrap: balance;
}

.bga-02__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.3vw,16px);
  line-height: 1.6;
  color: oklch(0.9 0.01 250/.8);
  text-wrap: pretty;
}

.bga-02__meta {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
  margin-top: 4px;
}

.bga-02__meta li {
  display: grid;
  gap: 2px;
  min-width: 112px;
  padding: 11px 16px;
  border-radius: 14px;
  background: oklch(1 0 0/.05);
  border: 1px solid oklch(1 0 0/.1);
}

.bga-02__meta b {
  font-size: 22px;
  font-weight: 660;
  letter-spacing: -.03em;
  color: var(--bga-02-acc);
}

.bga-02__meta span {
  font-size: 10.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: oklch(0.8 0.01 250/.65);
}

.bga-02__hint {
  font-size: 12.5px;
  color: oklch(0.85 0.01 250/.5);
}

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

  .bga-02.is-idle {
    animation: none;
  }
}
(() => {
  const root = document.getElementById('bga-02-root');
  if (!root) return;
  const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!calm) root.classList.add('is-idle');

  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    root.classList.remove('is-idle');
    root.style.setProperty('--bga-02-mx', ((e.clientX - r.left) / r.width * 100).toFixed(2) + '%');
    root.style.setProperty('--bga-02-my', ((e.clientY - r.top) / r.height * 100).toFixed(2) + '%');
  }, { passive: true });

  root.addEventListener('pointerleave', () => {
    root.style.removeProperty('--bga-02-mx');
    root.style.removeProperty('--bga-02-my');
    if (!calm) root.classList.add('is-idle');
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Background Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Cursor Spotlight Follow Effect
Source: https://codefronts.com/motion/css-background-animations/cursor-spotlight-follow-effect/

A torch dragged across a dark surface. Two lines of pointer JavaScript write --mx / --my; everything else — the light falloff, the mask that reveals a hidden colour layer underneath, the grid that brightens only inside the beam — is CSS. It idles on its own orbit until you move, so it is never a dead rectangle on touch devices, and it hard-stops for reduced-motion users.
## HTML
```html
<section class="bga-02" id="bga-02-root" aria-label="Cursor spotlight follow background demo">
  <div class="bga-02__grid" aria-hidden="true"></div>
  <div class="bga-02__reveal" aria-hidden="true">
    <img class="bga-02__photo" src="https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1800&auto=format&fit=crop" alt="" width="1800" height="1200" loading="lazy" decoding="async">
  </div>
  <div class="bga-02__beam" aria-hidden="true"></div>
  <div class="bga-02__vig" aria-hidden="true"></div>

  <div class="bga-02__stage">
    <div class="bga-02__panel">
      <p class="bga-02__eyebrow">Pointer-reactive · 02</p>
      <h1 class="bga-02__title">Move the light.<br>The surface answers.</h1>
      <p class="bga-02__sub">Two custom properties are written on pointermove. The falloff, the mask, the grid glow and the easing are all CSS — the main thread does almost nothing.</p>
      <ul class="bga-02__meta">
        <li><b>0</b><span>rAF loops</span></li>
        <li><b>2</b><span>properties written</span></li>
        <li><b>1</b><span>passive listener</span></li>
      </ul>
    </div>
    <p class="bga-02__hint">Move your cursor — or wait, and it orbits on its own.</p>
  </div>
</section>
```
## CSS
```css
@property --bga-02-mx {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 50%;
}

@property --bga-02-my {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 44%;
}

.bga-02,
.bga-02 *,
.bga-02 *::before,
.bga-02 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bga-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  overflow: hidden;
  isolation: isolate;
  --bga-02-bg: oklch(0.16 0.024 265);
  --bga-02-acc: oklch(0.78 0.16 232);
  --bga-02-acc2: oklch(0.7 0.19 320);
  --bga-02-r: clamp(240px,32vw,460px);
  --bga-02-mx: 50%;
  --bga-02-my: 44%;
  background: var(--bga-02-bg);
  color: oklch(0.97 0.01 250);
  font-family: ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  -webkit-font-smoothing: antialiased;
  transition: --bga-02-mx .45s cubic-bezier(.16,1,.3,1),--bga-02-my .45s cubic-bezier(.16,1,.3,1);
}

.bga-02.is-idle {
  animation: bga-02-orbit 16s ease-in-out infinite;
}

@keyframes bga-02-orbit {
  0% {
    --bga-02-mx: 26%;
    --bga-02-my: 32%;
  }

  25% {
    --bga-02-mx: 74%;
    --bga-02-my: 26%;
  }

  50% {
    --bga-02-mx: 80%;
    --bga-02-my: 74%;
  }

  75% {
    --bga-02-mx: 22%;
    --bga-02-my: 70%;
  }

  100% {
    --bga-02-mx: 26%;
    --bga-02-my: 32%;
  }
}

.bga-02__grid {
  position: absolute;
  inset: 0;
  opacity: .5;
  background-image: linear-gradient(oklch(1 0 0/.07) 1px,transparent 1px),linear-gradient(90deg,oklch(1 0 0/.07) 1px,transparent 1px);
  background-size: 56px 56px;
}

.bga-02__reveal {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg,color-mix(in oklab,var(--bga-02-acc) 30%,transparent),color-mix(in oklab,var(--bga-02-acc2) 34%,transparent)), linear-gradient(oklch(1 0 0/.3) 1px,transparent 1px), linear-gradient(90deg,oklch(1 0 0/.3) 1px,transparent 1px);
  background-size: 100% 100%,56px 56px,56px 56px;
  -webkit-mask-image: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),oklch(0 0 0) 0 38%,transparent 74%);
  mask-image: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),oklch(0 0 0) 0 38%,transparent 74%);
}

.bga-02__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: .62;
  mix-blend-mode: luminosity;
}

.bga-02__beam {
  position: absolute;
  inset: 0;
  mix-blend-mode: screen;
  background: radial-gradient(circle var(--bga-02-r) at var(--bga-02-mx) var(--bga-02-my),
      color-mix(in oklab,var(--bga-02-acc) 46%,transparent) 0%,
      color-mix(in oklab,var(--bga-02-acc) 14%,transparent) 42%,transparent 70%);
}

.bga-02__vig {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(130% 110% at 50% 40%,transparent 40%,oklch(0.08 0.02 265/.85) 100%);
}

.bga-02__stage {
  position: relative;
  z-index: 3;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 22px;
  padding: clamp(22px,5vw,68px);
  pointer-events: none;
}

.bga-02__panel {
  width: min(100%,700px);
  display: grid;
  gap: 16px;
  padding: clamp(24px,3.4vw,44px);
  border-radius: 24px;
  text-align: center;
  justify-items: center;
  background: oklch(0.14 0.02 265/.55);
  border: 1px solid oklch(1 0 0/.12);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow: 0 40px 90px -40px oklch(0 0 0/.9);
}

.bga-02__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--bga-02-acc);
}

.bga-02__title {
  font-size: clamp(28px,5.6vw,58px);
  font-weight: 640;
  line-height: 1.05;
  letter-spacing: -.045em;
  text-wrap: balance;
}

.bga-02__sub {
  max-width: 50ch;
  font-size: clamp(13.5px,1.3vw,16px);
  line-height: 1.6;
  color: oklch(0.9 0.01 250/.8);
  text-wrap: pretty;
}

.bga-02__meta {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
  margin-top: 4px;
}

.bga-02__meta li {
  display: grid;
  gap: 2px;
  min-width: 112px;
  padding: 11px 16px;
  border-radius: 14px;
  background: oklch(1 0 0/.05);
  border: 1px solid oklch(1 0 0/.1);
}

.bga-02__meta b {
  font-size: 22px;
  font-weight: 660;
  letter-spacing: -.03em;
  color: var(--bga-02-acc);
}

.bga-02__meta span {
  font-size: 10.5px;
  letter-spacing: .09em;
  text-transform: uppercase;
  color: oklch(0.8 0.01 250/.65);
}

.bga-02__hint {
  font-size: 12.5px;
  color: oklch(0.85 0.01 250/.5);
}

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

  .bga-02.is-idle {
    animation: none;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.getElementById('bga-02-root');
  if (!root) return;
  const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!calm) root.classList.add('is-idle');

  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    root.classList.remove('is-idle');
    root.style.setProperty('--bga-02-mx', ((e.clientX - r.left) / r.width * 100).toFixed(2) + '%');
    root.style.setProperty('--bga-02-my', ((e.clientY - r.top) / r.height * 100).toFixed(2) + '%');
  }, { passive: true });

  root.addEventListener('pointerleave', () => {
    root.style.removeProperty('--bga-02-mx');
    root.style.removeProperty('--bga-02-my');
    if (!calm) root.classList.add('is-idle');
  });
})();
```

How this works

The whole effect is one moving radial gradient. JavaScript's only job is to publish the pointer position as two registered percentages:

root.addEventListener('pointermove', e => {
  const r = root.getBoundingClientRect();
  root.style.setProperty('--mx', ((e.clientX - r.left) / r.width  * 100) + '%');
  root.style.setProperty('--my', ((e.clientY - r.top)  / r.height * 100) + '%');
}, { passive: true });

Registering them with @property as <percentage> lets a short transition smooth the value, which is what turns a jittery mouse trail into a weighted, physical-feeling light. No requestAnimationFrame loop, no lerp maths.

Two layers share that position. The first is the beam itself:

.beam { background: radial-gradient(circle var(--r) at var(--mx) var(--my),
        oklch(.85 .17 250/.5), transparent 70%); }

The second is the reveal — a photograph plus a saturated colour and grid layer that exists at all times but is masked to the same circle, so the spotlight does not just brighten the surface, it uncovers a different one. The image is hotlinked from Unsplash with explicit dimensions and loading="lazy", sitting on the gradient so a failed request degrades instead of collapsing:

.reveal { mask-image: radial-gradient(circle var(--r) at var(--mx) var(--my), #000 0 40%, transparent 72%); }

Before the first pointer event the custom properties hold an animated idle orbit, so the demo is alive on load and on touch. The listener is { passive: true } and writes only custom properties, so the browser stays on the compositor path and never runs layout.

Make it yours

  • Change --bga-02-r to resize the beam; animate it on :active for a torch that flares when the user presses.
  • Swap the revealed photograph for a product shot, a second brand colour, or an entire hidden headline — the mask does not care what it uncovers.
  • Add mask-composite: intersect with a second static mask to keep the beam inside a shape (a logo, a circle, a diagonal band).
  • For a multi-user or demo-reel feel, keep the idle orbit running permanently and blend it with the pointer position using calc() on both.

Gotchas — read before shipping

  • Never update these values inside a rAF loop that also reads layout — getBoundingClientRect() per pointermove is fine because pointermove is already throttled to frame rate, but reading it per frame in a loop is not.
  • Transitioning an unregistered custom property does nothing. Register --mx/--my with @property or the light will teleport.
  • mask-image needs the -webkit- prefix for older Safari, and a masked layer gets its own compositing layer — keep it to one full-bleed element.
  • Pointer events never fire on touch-only devices. Ship the idle animation as this demo does, or the background is simply broken on half your traffic.
  • A bright beam over body copy destroys contrast. Keep readable text inside a panel with its own background, not directly on the lit surface.

Browser support

ChromeSafariFirefoxEdge
114+17.5+128+114+

Floor set by oklch(), color-mix(), backdrop-filter, @property, text-wrap as this demo is written. The core technique itself goes back further — Chrome 85+.

@property (Chrome 85 / Safari 16.4 / Firefox 128) enables the smoothing transition; without it the spotlight still tracks, just without easing. CSS mask-image is universal with -webkit- prefix. Pointer Events are supported everywhere since 2019.

Techniques used in this demo

Search CodeFronts

Loading…