25 CSS Background Animations02 / 25
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
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><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;
}
}@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');
});
})();(() => {
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');
});
})();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');
});
})();
```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-rto resize the beam; animate it on:activefor 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: intersectwith 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/--mywith@propertyor the light will teleport. mask-imageneeds 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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.