6 CSS Custom Cursor Styles02 / 06
Smooth Glowing Cursor Follower with a Trailing Ring using requestAnimationFrame Lerp
The signature portfolio/agency effect: a crisp dot that tracks the pointer instantly, plus a larger glowing ring that eases behind it with a springy trailing lag. The native cursor is hidden and replaced by these two pointer-events-none layers. Built on requestAnimationFrame linear interpolation — smooth at any frame rate — and it politely disables itself for reduced-motion and touch users.
Published
The code
<section class="ccs-02">
<div class="ccs-02__stage" data-stage>
<div class="ccs-02__ring" data-ring aria-hidden="true"></div>
<div class="ccs-02__dot" data-dot aria-hidden="true"></div>
<div class="ccs-02__content">
<span class="ccs-02__eyebrow">Portfolio · hero</span>
<h2>A cursor that trails behind you</h2>
<p>The dot locks to your pointer; the ring eases in with a springy lag. Pure <code>requestAnimationFrame</code> interpolation.</p>
<a class="ccs-02__link" href="#" data-hover>See the work</a>
</div>
</div>
</section><section class="ccs-02">
<div class="ccs-02__stage" data-stage>
<div class="ccs-02__ring" data-ring aria-hidden="true"></div>
<div class="ccs-02__dot" data-dot aria-hidden="true"></div>
<div class="ccs-02__content">
<span class="ccs-02__eyebrow">Portfolio · hero</span>
<h2>A cursor that trails behind you</h2>
<p>The dot locks to your pointer; the ring eases in with a springy lag. Pure <code>requestAnimationFrame</code> interpolation.</p>
<a class="ccs-02__link" href="#" data-hover>See the work</a>
</div>
</div>
</section>.ccs-02,
.ccs-02 *,
.ccs-02 *::before,
.ccs-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccs-02 {
--bg: oklch(0.15 0.03 275);
--text: oklch(0.96 0.01 275);
--accent: oklch(0.78 0.16 200);
--muted: color-mix(in oklab,var(--text) 55%,var(--bg));
color-scheme: dark;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
padding: 28px;
font-family: system-ui,'Segoe UI',sans-serif;
background: var(--bg);
color: var(--text);
}
.ccs-02__stage {
position: relative;
width: min(560px,100%);
min-height: 340px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(130% 100% at 20% 0%,oklch(0.24 0.06 285),var(--bg));
border: 1px solid oklch(0.34 0.03 275);
}
.ccs-02__content {
position: relative;
z-index: 1;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 36px;
text-align: left;
}
.ccs-02__eyebrow {
font: 600 11px/1 ui-monospace,Menlo,monospace;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--accent);
}
.ccs-02__content h2 {
font: 700 clamp(22px,4vw,30px)/1.12 system-ui,sans-serif;
letter-spacing: -.02em;
}
.ccs-02__content p {
font: 400 14.5px/1.6 system-ui,sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.ccs-02__content code {
font: 600 12.5px/1 ui-monospace,Menlo,monospace;
padding: 2px 5px;
border-radius: 5px;
background: oklch(1 0 0/.08);
color: var(--text);
}
.ccs-02__link {
align-self: flex-start;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 20px;
margin-top: 6px;
border-radius: 999px;
font: 600 13.5px/1 system-ui,sans-serif;
text-decoration: none;
color: var(--bg);
background: var(--accent);
}
.ccs-02__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.ccs-02__dot,
.ccs-02__ring {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease;
}
.ccs-02__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
z-index: 3;
}
.ccs-02__ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1.5px solid var(--accent);
box-shadow: 0 0 26px oklch(from var(--accent) l c h/.55);
z-index: 2;
}
.ccs-02__stage.is-live .ccs-02__dot,
.ccs-02__stage.is-live .ccs-02__ring {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.ccs-02__stage {
cursor: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ccs-02__stage {
cursor: auto;
}
.ccs-02__dot,
.ccs-02__ring {
display: none;
}
}.ccs-02,
.ccs-02 *,
.ccs-02 *::before,
.ccs-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccs-02 {
--bg: oklch(0.15 0.03 275);
--text: oklch(0.96 0.01 275);
--accent: oklch(0.78 0.16 200);
--muted: color-mix(in oklab,var(--text) 55%,var(--bg));
color-scheme: dark;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
padding: 28px;
font-family: system-ui,'Segoe UI',sans-serif;
background: var(--bg);
color: var(--text);
}
.ccs-02__stage {
position: relative;
width: min(560px,100%);
min-height: 340px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(130% 100% at 20% 0%,oklch(0.24 0.06 285),var(--bg));
border: 1px solid oklch(0.34 0.03 275);
}
.ccs-02__content {
position: relative;
z-index: 1;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 36px;
text-align: left;
}
.ccs-02__eyebrow {
font: 600 11px/1 ui-monospace,Menlo,monospace;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--accent);
}
.ccs-02__content h2 {
font: 700 clamp(22px,4vw,30px)/1.12 system-ui,sans-serif;
letter-spacing: -.02em;
}
.ccs-02__content p {
font: 400 14.5px/1.6 system-ui,sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.ccs-02__content code {
font: 600 12.5px/1 ui-monospace,Menlo,monospace;
padding: 2px 5px;
border-radius: 5px;
background: oklch(1 0 0/.08);
color: var(--text);
}
.ccs-02__link {
align-self: flex-start;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 20px;
margin-top: 6px;
border-radius: 999px;
font: 600 13.5px/1 system-ui,sans-serif;
text-decoration: none;
color: var(--bg);
background: var(--accent);
}
.ccs-02__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.ccs-02__dot,
.ccs-02__ring {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease;
}
.ccs-02__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
z-index: 3;
}
.ccs-02__ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1.5px solid var(--accent);
box-shadow: 0 0 26px oklch(from var(--accent) l c h/.55);
z-index: 2;
}
.ccs-02__stage.is-live .ccs-02__dot,
.ccs-02__stage.is-live .ccs-02__ring {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.ccs-02__stage {
cursor: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ccs-02__stage {
cursor: auto;
}
.ccs-02__dot,
.ccs-02__ring {
display: none;
}
}(function () {
var stage = document.querySelector('.ccs-02__stage');
var dot = stage.querySelector('[data-dot]');
var ring = stage.querySelector('[data-ring]');
var fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
if (!fine || reduce) return; // touch / reduced-motion → native cursor
var mx = 0, my = 0, rx = 0, ry = 0, raf = 0;
stage.addEventListener('pointerenter', function (e) {
var r = stage.getBoundingClientRect();
mx = rx = e.clientX - r.left; my = ry = e.clientY - r.top;
stage.classList.add('is-live');
});
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
mx = e.clientX - r.left; my = e.clientY - r.top;
});
stage.addEventListener('pointerleave', function () { stage.classList.remove('is-live'); });
// hover affordance: fatten the ring over interactive elements
stage.querySelectorAll('[data-hover]').forEach(function (el) {
el.addEventListener('pointerenter', function () { ring.style.scale = '1.8'; });
el.addEventListener('pointerleave', function () { ring.style.scale = '1'; });
});
function loop() {
rx += (mx - rx) * 0.15; ry += (my - ry) * 0.15;
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px) translate(-50%,-50%)';
dot.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
})();(function () {
var stage = document.querySelector('.ccs-02__stage');
var dot = stage.querySelector('[data-dot]');
var ring = stage.querySelector('[data-ring]');
var fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
if (!fine || reduce) return; // touch / reduced-motion → native cursor
var mx = 0, my = 0, rx = 0, ry = 0, raf = 0;
stage.addEventListener('pointerenter', function (e) {
var r = stage.getBoundingClientRect();
mx = rx = e.clientX - r.left; my = ry = e.clientY - r.top;
stage.classList.add('is-live');
});
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
mx = e.clientX - r.left; my = e.clientY - r.top;
});
stage.addEventListener('pointerleave', function () { stage.classList.remove('is-live'); });
// hover affordance: fatten the ring over interactive elements
stage.querySelectorAll('[data-hover]').forEach(function (el) {
el.addEventListener('pointerenter', function () { ring.style.scale = '1.8'; });
el.addEventListener('pointerleave', function () { ring.style.scale = '1'; });
});
function loop() {
rx += (mx - rx) * 0.15; ry += (my - ry) * 0.15;
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px) translate(-50%,-50%)';
dot.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
})();Here's a working CSS Custom Cursor Style 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: Smooth Glowing Cursor Follower with a Trailing Ring using requestAnimationFrame Lerp
Source: https://codefronts.com/snippets/css-custom-cursor/smooth-glowing-follower/
The signature portfolio/agency effect: a crisp dot that tracks the pointer instantly, plus a larger glowing ring that eases behind it with a springy trailing lag. The native cursor is hidden and replaced by these two pointer-events-none layers. Built on requestAnimationFrame linear interpolation — smooth at any frame rate — and it politely disables itself for reduced-motion and touch users.
## HTML
```html
<section class="ccs-02">
<div class="ccs-02__stage" data-stage>
<div class="ccs-02__ring" data-ring aria-hidden="true"></div>
<div class="ccs-02__dot" data-dot aria-hidden="true"></div>
<div class="ccs-02__content">
<span class="ccs-02__eyebrow">Portfolio · hero</span>
<h2>A cursor that trails behind you</h2>
<p>The dot locks to your pointer; the ring eases in with a springy lag. Pure <code>requestAnimationFrame</code> interpolation.</p>
<a class="ccs-02__link" href="#" data-hover>See the work</a>
</div>
</div>
</section>
```
## CSS
```css
.ccs-02,
.ccs-02 *,
.ccs-02 *::before,
.ccs-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccs-02 {
--bg: oklch(0.15 0.03 275);
--text: oklch(0.96 0.01 275);
--accent: oklch(0.78 0.16 200);
--muted: color-mix(in oklab,var(--text) 55%,var(--bg));
color-scheme: dark;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
padding: 28px;
font-family: system-ui,'Segoe UI',sans-serif;
background: var(--bg);
color: var(--text);
}
.ccs-02__stage {
position: relative;
width: min(560px,100%);
min-height: 340px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(130% 100% at 20% 0%,oklch(0.24 0.06 285),var(--bg));
border: 1px solid oklch(0.34 0.03 275);
}
.ccs-02__content {
position: relative;
z-index: 1;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 36px;
text-align: left;
}
.ccs-02__eyebrow {
font: 600 11px/1 ui-monospace,Menlo,monospace;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--accent);
}
.ccs-02__content h2 {
font: 700 clamp(22px,4vw,30px)/1.12 system-ui,sans-serif;
letter-spacing: -.02em;
}
.ccs-02__content p {
font: 400 14.5px/1.6 system-ui,sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.ccs-02__content code {
font: 600 12.5px/1 ui-monospace,Menlo,monospace;
padding: 2px 5px;
border-radius: 5px;
background: oklch(1 0 0/.08);
color: var(--text);
}
.ccs-02__link {
align-self: flex-start;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 20px;
margin-top: 6px;
border-radius: 999px;
font: 600 13.5px/1 system-ui,sans-serif;
text-decoration: none;
color: var(--bg);
background: var(--accent);
}
.ccs-02__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.ccs-02__dot,
.ccs-02__ring {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease;
}
.ccs-02__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
z-index: 3;
}
.ccs-02__ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1.5px solid var(--accent);
box-shadow: 0 0 26px oklch(from var(--accent) l c h/.55);
z-index: 2;
}
.ccs-02__stage.is-live .ccs-02__dot,
.ccs-02__stage.is-live .ccs-02__ring {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.ccs-02__stage {
cursor: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ccs-02__stage {
cursor: auto;
}
.ccs-02__dot,
.ccs-02__ring {
display: none;
}
}
```
## JavaScript
```js
(function () {
var stage = document.querySelector('.ccs-02__stage');
var dot = stage.querySelector('[data-dot]');
var ring = stage.querySelector('[data-ring]');
var fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
if (!fine || reduce) return; // touch / reduced-motion → native cursor
var mx = 0, my = 0, rx = 0, ry = 0, raf = 0;
stage.addEventListener('pointerenter', function (e) {
var r = stage.getBoundingClientRect();
mx = rx = e.clientX - r.left; my = ry = e.clientY - r.top;
stage.classList.add('is-live');
});
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
mx = e.clientX - r.left; my = e.clientY - r.top;
});
stage.addEventListener('pointerleave', function () { stage.classList.remove('is-live'); });
// hover affordance: fatten the ring over interactive elements
stage.querySelectorAll('[data-hover]').forEach(function (el) {
el.addEventListener('pointerenter', function () { ring.style.scale = '1.8'; });
el.addEventListener('pointerleave', function () { ring.style.scale = '1'; });
});
function loop() {
rx += (mx - rx) * 0.15; ry += (my - ry) * 0.15;
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px) translate(-50%,-50%)';
dot.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
})();
```Here's a working CSS Custom Cursor Style 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: Smooth Glowing Cursor Follower with a Trailing Ring using requestAnimationFrame Lerp
Source: https://codefronts.com/snippets/css-custom-cursor/smooth-glowing-follower/
The signature portfolio/agency effect: a crisp dot that tracks the pointer instantly, plus a larger glowing ring that eases behind it with a springy trailing lag. The native cursor is hidden and replaced by these two pointer-events-none layers. Built on requestAnimationFrame linear interpolation — smooth at any frame rate — and it politely disables itself for reduced-motion and touch users.
## HTML
```html
<section class="ccs-02">
<div class="ccs-02__stage" data-stage>
<div class="ccs-02__ring" data-ring aria-hidden="true"></div>
<div class="ccs-02__dot" data-dot aria-hidden="true"></div>
<div class="ccs-02__content">
<span class="ccs-02__eyebrow">Portfolio · hero</span>
<h2>A cursor that trails behind you</h2>
<p>The dot locks to your pointer; the ring eases in with a springy lag. Pure <code>requestAnimationFrame</code> interpolation.</p>
<a class="ccs-02__link" href="#" data-hover>See the work</a>
</div>
</div>
</section>
```
## CSS
```css
.ccs-02,
.ccs-02 *,
.ccs-02 *::before,
.ccs-02 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.ccs-02 {
--bg: oklch(0.15 0.03 275);
--text: oklch(0.96 0.01 275);
--accent: oklch(0.78 0.16 200);
--muted: color-mix(in oklab,var(--text) 55%,var(--bg));
color-scheme: dark;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
padding: 28px;
font-family: system-ui,'Segoe UI',sans-serif;
background: var(--bg);
color: var(--text);
}
.ccs-02__stage {
position: relative;
width: min(560px,100%);
min-height: 340px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 24px;
background: radial-gradient(130% 100% at 20% 0%,oklch(0.24 0.06 285),var(--bg));
border: 1px solid oklch(0.34 0.03 275);
}
.ccs-02__content {
position: relative;
z-index: 1;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 36px;
text-align: left;
}
.ccs-02__eyebrow {
font: 600 11px/1 ui-monospace,Menlo,monospace;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--accent);
}
.ccs-02__content h2 {
font: 700 clamp(22px,4vw,30px)/1.12 system-ui,sans-serif;
letter-spacing: -.02em;
}
.ccs-02__content p {
font: 400 14.5px/1.6 system-ui,sans-serif;
color: var(--muted);
text-wrap: pretty;
}
.ccs-02__content code {
font: 600 12.5px/1 ui-monospace,Menlo,monospace;
padding: 2px 5px;
border-radius: 5px;
background: oklch(1 0 0/.08);
color: var(--text);
}
.ccs-02__link {
align-self: flex-start;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 20px;
margin-top: 6px;
border-radius: 999px;
font: 600 13.5px/1 system-ui,sans-serif;
text-decoration: none;
color: var(--bg);
background: var(--accent);
}
.ccs-02__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.ccs-02__dot,
.ccs-02__ring {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease;
}
.ccs-02__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
z-index: 3;
}
.ccs-02__ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1.5px solid var(--accent);
box-shadow: 0 0 26px oklch(from var(--accent) l c h/.55);
z-index: 2;
}
.ccs-02__stage.is-live .ccs-02__dot,
.ccs-02__stage.is-live .ccs-02__ring {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.ccs-02__stage {
cursor: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ccs-02__stage {
cursor: auto;
}
.ccs-02__dot,
.ccs-02__ring {
display: none;
}
}
```
## JavaScript
```js
(function () {
var stage = document.querySelector('.ccs-02__stage');
var dot = stage.querySelector('[data-dot]');
var ring = stage.querySelector('[data-ring]');
var fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
if (!fine || reduce) return; // touch / reduced-motion → native cursor
var mx = 0, my = 0, rx = 0, ry = 0, raf = 0;
stage.addEventListener('pointerenter', function (e) {
var r = stage.getBoundingClientRect();
mx = rx = e.clientX - r.left; my = ry = e.clientY - r.top;
stage.classList.add('is-live');
});
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
mx = e.clientX - r.left; my = e.clientY - r.top;
});
stage.addEventListener('pointerleave', function () { stage.classList.remove('is-live'); });
// hover affordance: fatten the ring over interactive elements
stage.querySelectorAll('[data-hover]').forEach(function (el) {
el.addEventListener('pointerenter', function () { ring.style.scale = '1.8'; });
el.addEventListener('pointerleave', function () { ring.style.scale = '1'; });
});
function loop() {
rx += (mx - rx) * 0.15; ry += (my - ry) * 0.15;
ring.style.transform = 'translate(' + rx + 'px,' + ry + 'px) translate(-50%,-50%)';
dot.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
})();
```How this works
Two absolutely-positioned layers sit inside the stage: a small dot and a larger ring, both with pointer-events:none so they never intercept clicks. A pointermove listener stores the latest target coordinates. Inside a requestAnimationFrame loop, the ring's current position is nudged toward the target each frame with linear interpolation — current += (target − current) * 0.15. A smaller factor means more lag; a larger one snaps faster. The dot uses the target directly, so it feels locked to the pointer while the ring drifts behind it.
Positioning uses transform: translate() (compositor-friendly, no layout thrash) with translate(-50%,-50%) to center each layer on the coordinate. The native cursor is removed with cursor:none only where the replacement is visible. On pointerleave the layers fade out; a prefers-reduced-motion check skips the whole animation and restores the system cursor, and the effect only initializes under (hover:hover) and (pointer:fine).
Techniques used: requestAnimationFrame lerp (frame-rate independent easing) · dual-layer dot + trailing ring · transform: translate() for GPU-friendly positioning · pointer-events:none so layers never block clicks · Pointer Events API (pointermove / pointerleave) · prefers-reduced-motion + hover/pointer feature guards
let rx = 0, ry = 0; // ring's eased position
function loop() {
rx += (mx - rx) * 0.15; // ease 15% toward the target each frame
ry += (my - ry) * 0.15;
ring.style.transform = `translate(${rx}px, ${ry}px) translate(-50%,-50%)`;
dot.style.transform = `translate(${mx}px, ${my}px) translate(-50%,-50%)`;
requestAnimationFrame(loop);
}Make it yours
- Add a hover state: on
[data-hover]elements, scale the ring up and drop the dot for a classic 'focus' feel (see Demo 03's :has() trick). - Tune the
0.15lerp factor — lower is dreamier, higher is snappier. 0.1–0.2 covers most tastes. - Give the ring
mix-blend-mode:differenceand a white fill so it inverts whatever it passes over. - Add a soft glow with
box-shadoworfilter: blur()on a third layer for the 'glowing orb' look.
Gotchas — read before shipping
- Never animate
left/topin the loop — that triggers layout every frame; animatetransformonly. - Always guard with
prefers-reduced-motionand restore the native cursor, or you exclude motion-sensitive users. - Hiding the system cursor site-wide is risky — scope
cursor:noneto the area with a visible replacement so users never 'lose' their pointer. - Cancel the RAF loop on cleanup in SPAs, or you leak a running animation per navigation.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by oklch(), color-mix(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 55+.
Pointer Events + requestAnimationFrame are universal in evergreen browsers. mix-blend-mode (if used) is Chrome 41+/Safari 8+/Firefox 32+.