6 CSS Custom Cursor Styles02 / 06

CSS + JSMIT licensed

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

Live Demo
Try it

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>
.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);
})();
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 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 interpolationcurrent += (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.15 lerp factor — lower is dreamier, higher is snappier. 0.1–0.2 covers most tastes.
  • Give the ring mix-blend-mode:difference and a white fill so it inverts whatever it passes over.
  • Add a soft glow with box-shadow or filter: blur() on a third layer for the 'glowing orb' look.

Gotchas — read before shipping

  • Never animate left/top in the loop — that triggers layout every frame; animate transform only.
  • Always guard with prefers-reduced-motion and restore the native cursor, or you exclude motion-sensitive users.
  • Hiding the system cursor site-wide is risky — scope cursor:none to 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

ChromeSafariFirefoxEdge
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+.

Techniques used in this demo

Search CodeFronts

Loading…