Cursor effect

Dot and ring cursor follower: copy-paste CSS and JS

A small dot sits exactly on the pointer while a thin ring follows a beat behind and grows over links. The system cursor stays visible, so nobody loses their place.

The effect running on this page, with exactly the code below. Move your mouse inside the stage: the effect stays inside it. On a touch screen, or with reduced motion turned on, it holds back, as it should.

Usage

When to use it

  • Portfolios and studio sites where the cursor is part of the brand.
  • Launch pages and landing pages with large, sparse layouts.
  • Galleries where the growing ring makes clickable tiles obvious.
  • Keep the native cursor on, as this snippet does. Pair it with the hide-native-cursor pattern only if your design truly needs it.

Code

Copy the effect

HTML
<div class="cursor-dot" aria-hidden="true"></div>
<div class="cursor-ring" aria-hidden="true"></div>
CSS
.cursor-dot,
.cursor-ring {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  box-sizing: border-box;
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.cursor-dot {
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  background: #2d5bff;
}
.cursor-ring {
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  border: 1.5px solid #2d5bff;
  transition: opacity 0.2s ease, width 0.2s ease, height 0.2s ease, margin 0.2s ease;
}
.cursor-ring.is-hovering {
  width: 56px;
  height: 56px;
  margin: -28px 0 0 -28px;
}
.has-follower .cursor-dot,
.has-follower .cursor-ring {
  opacity: 1;
}
@media (hover: none), (pointer: coarse) {
  .cursor-dot,
  .cursor-ring { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .cursor-ring { transition: opacity 0.2s ease; }
}
JavaScript
(() => {
  const dot = document.querySelector('.cursor-dot');
  const ring = document.querySelector('.cursor-ring');
  const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  if (!dot || !ring || !finePointer.matches) return;

  const HOVER_TARGETS = 'a, button, [role="button"], label, summary, select, [data-cursor-hover]';
  const LAG = 0.18; // 0 to 1. Lower values make the ring trail further behind.
  let mouseX = 0, mouseY = 0, ringX = 0, ringY = 0;
  let frame = 0;
  let visible = false;

  function render() {
    const ease = reduceMotion.matches ? 1 : LAG; // reduced motion: the ring snaps, no trailing
    ringX += (mouseX - ringX) * ease;
    ringY += (mouseY - ringY) * ease;
    dot.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
    ring.style.transform = `translate3d(${ringX}px, ${ringY}px, 0)`;
    const settled = Math.abs(mouseX - ringX) < 0.1 && Math.abs(mouseY - ringY) < 0.1;
    frame = settled ? 0 : requestAnimationFrame(render);
  }

  function hide() {
    visible = false;
    document.documentElement.classList.remove('has-follower');
  }

  document.addEventListener('pointermove', (event) => {
    if (event.pointerType !== 'mouse') return;
    mouseX = event.clientX;
    mouseY = event.clientY;
    if (!visible) {
      ringX = mouseX;
      ringY = mouseY;
      visible = true;
      document.documentElement.classList.add('has-follower');
    }
    if (!frame) frame = requestAnimationFrame(render);
  }, { passive: true });

  document.addEventListener('pointerover', (event) => {
    const target = event.target instanceof Element ? event.target : null;
    ring.classList.toggle('is-hovering', Boolean(target && target.closest(HOVER_TARGETS)));
  });

  document.addEventListener('mouseout', (event) => {
    if (!event.relatedTarget) hide();
  });
  window.addEventListener('blur', hide);
})();

Accessibility

Motion, touch and screen readers

Reduced motion

With prefers-reduced-motion: reduce, the script sets the ring's easing to 1, so the ring moves exactly with the pointer instead of trailing behind it, and the CSS drops the ring's size transition so it changes size instantly over links. The script reads the setting on every frame, so it follows a change made while the page is open. The dot always sits on the pointer and never animates.

Accessibility notes

  • The native cursor stays visible. The dot and ring are decoration on top of it, so pointer size and colour settings still work.
  • Both elements have aria-hidden="true" and pointer-events: none, so screen readers skip them and they never block a click.
  • The script only runs with a mouse or trackpad ((hover: hover) and (pointer: fine)), and the CSS hides both elements on touch screens.
  • Pick a colour that contrasts with every background the ring passes over, or the hover cue disappears on dark sections.
  • Keyboard users do not see the ring, so links still need their own :focus-visible style.

Site builders

Webflow, Framer and React

Webflow

In Webflow, put the CSS inside a <style> tag and the JavaScript inside a <script> tag in the page's custom code (Before </body> tag), or site-wide in Site settings > Custom code. Add the two divs with an Embed element near the end of the body, or build them as divs with the classes cursor-dot and cursor-ring. Custom code runs on the published site, not in the Designer canvas.

Framer

Framer's own Cursor settings can attach a component as a custom cursor without code. To use this snippet instead, paste the CSS and JS into Site settings > General > Custom Code (end of <body>) and add the two divs in an Embed. Because Framer swaps pages without a full reload, keep the script document-wide as written, not tied to elements on one page.

React

Render the two divs once in your root layout. Move the JavaScript into a useEffect with an empty dependency array, keep references to the listeners, and remove them plus cancelAnimationFrame(frame) in the cleanup. Read matchMedia inside the effect so server rendering never touches window.

FAQ

Questions

Does this hide the normal cursor?
No. The dot and ring sit on top of the system cursor. If you want to hide it, use the hide-native-cursor pattern, scoped to one section, and keep it off form fields.
How do I make the ring trail further behind?
Lower the LAG value, for example to 0.1. Keep it above about 0.08 or the ring feels disconnected from the pointer.
How do I make other elements grow the ring?
Add data-cursor-hover to them, or add their selector to HOVER_TARGETS.