CursorsFree

Dot Follower

An 8 px dot glued to the pointer and a 40 px ring that catches up late — 15% of the remaining gap per frame, slowed further by a 150 ms CSS transition — both position: fixed and shown only while the mouse is inside the zone.

JSSmooth
Survolez cette zone Curseur point + anneau
Hover or click the scene to interact

This effect is free — the Cursors category contains 26 effects total, including 1 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

Studio · Art direction

Ardent

Identities, websites and films for brands that dare.
BrandingWebMotion

Move the mouse across the scene

① Design studio hero — dot and ring as a signature

WhenDark homepage of a studio or freelancer: title, tagline, one line of services.
WhyA custom cursor is a low-cost identity marker: an 8 px dot and a trailing ring are enough to signal “crafted site” from the first gesture, without adding anything to the content.
Settings48 px ring (offset −24), factor 0.12 for a more visible trail, transition removed so only the loop smooths the motion.
Recent projects 6 of 24
Nova — identity
Bloom — e-commerce
Tide — mobile app
Solar — packaging
Atlas — annual report
Ember — motion

Move the mouse across the grid

② Project grid — the cursor marks an explorable area

WhenThumbnail grid of a photo portfolio or agency: three to six cards, one title each.
WhyThe ring following the pointer invites sweeping across the grid; it stays neutral over images, where a system arrow looks foreign to the visual universe.
Settings6 px dot (offset −3), 56 px ring with a 1.5 px border, light color #f5f5f4, factor 0.1.
New · version 3.0

Dashboards you can finally read

Connect your data, pick a template, share a link. No SQL, no slide deck.

Move the mouse across the scene

③ SaaS landing — discreet cursor on the headline block

WhenSoftware product hero: badge, title, subtitle, two buttons.
WhyHere the cursor must not steal the show from the button: a compact, fast ring accompanies the gesture without slowing it, and the zone is limited to the hero so reading further down is untouched.
Settings32 px ring (offset −16), factor 0.25 (catch-up in about fifteen frames), .cursor-active { cursor: none } rule kept so only one arrow is on screen.

How it works

Two empty elements carry the cursor: #cursorDot (.cursor-dot, 8 × 8 px, #6366f1, border-radius: 50%) and #cursorRing (.cursor-ring, 40 × 40 px, border: 2px solid #6366f1). The shared class .custom-cursor takes them out of the flow: position: fixed, pointer-events: none (they never intercept a click), z-index: 9999 and display: none at rest. The #zone-dot area (300 px tall) gets three listeners: mouseenter switches both elements to display: block and adds the cursor-active class, mouseleave reverses it.

The dot has no delay: on every mousemove, left = clientX − 4 and top = clientY − 4, so its center sits exactly under the pointer (measured offset: 0 px). The ring never listens to the mouse: a permanent requestAnimationFrame loop computes ringX += (dotX − ringX) × 0.15 then writes left = ringX − 20. Each frame closes 15% of the remaining gap — an exponential approach: after a jump, 85% of the distance remains at frame 1, 20% at frame 10, under 1% past frame 30 (0.85³¹ × 456 px ≈ 3 px).

But .cursor-ring also declares transition: all .15s ease-out, which applies to left and top: every write from the loop restarts a 150 ms transition from the current position. Measured in Chromium at 120 Hz after a 456 px jump: 287 px of gap at frame 10, 55 px at frame 30, under 3 px only at frame 62 (≈ 0.5 s) — twice the 30 frames obtained with the transition removed. The smoothing is counted in frames, not milliseconds: on a 60 Hz screen the “loop” share takes twice as long.

Two initialization details matter. ringX and ringY start at 0: on first entry the ring flies in from the top-left corner of the window (measured: 23 px, 23 px at frame 0 for a pointer at 152, 150; still 17 px away 30 frames later). And the loop never stops — 121 requestAnimationFrame calls per second measured with no hover and the cursor hidden. Finally, the .cursor-zone rule defines, scoped on the zone, the five variables it reads (--bg-elevated: #1a1a24, --radius-xl: 1rem, --space-4: 1rem, --text-muted: #8b8b93, --text-secondary: #a1a1aa): on a blank page the zone renders with its background, radius and text colors, without imposing anything on the rest of the page.

Accessibility

  • The native cursor is hidden during hover: the JS adds the cursor-active class to the zone and the shipped CSS provides .cursor-active { cursor: none } — dot and ring are then the only pointer landmark. It is a setting: remove that rule to keep the system arrow, which is preferable if the zone holds precision click targets.
  • Dot and ring contrast (WCAG 1.4.11, UI component, 3:1 threshold): #6366f1 on #1a1a24 = 3.9:1, on #0a0a0f = 4.4:1, on white = 4.5:1 — compliant, but an 8 px disc is small. Once the native cursor is hidden, this dot is the only landmark: prefer #a5b4fc (8.7:1 on #1a1a24) or grow it to 10–12 px.
  • Touch: a tap fires the emulated mouse events. Measured with touch emulation: after a tap inside the zone, dot and ring appear frozen at the tapped point, and the cursor-active class stays even after a tap outside the zone (no mouseleave). Gate the initialization on matchMedia('(hover: hover) and (pointer: fine)').matches.
  • prefers-reduced-motion not handled: the gliding ring is an interaction-triggered animation (WCAG 2.3.3). Under @media (prefers-reduced-motion: reduce), hide the ring (.cursor-ring { display: none !important }) or change the 0.15 factor to 1 in the JS for a glued ring with no trail.
  • Screen readers and keyboard: both divs are empty and pointer-events: none, nothing is announced — still add aria-hidden="true" to each. No mousemove from the keyboard: interactive elements in the zone keep their native focus ring. Mind the z-index: 9999: the cursor floats above your modals and menus.

Browser compatibility

Requires ES2015 (const/let, arrow functions), classList, requestAnimationFrame and the CSS variables read by the zone. Zero dependencies.

Chrome 49+✓ Full
Firefox 45+✓ Full
Safari 10+✓ Full
Edge 15+✓ Full
Mobile iOS✓ Inactive (cursor frozen on tap)
Android Chrome✓ Inactive (cursor frozen on tap)

Without JavaScript, both elements stay display: none: the zone is a plain box and the native cursor does its job. Without CSS variables (pre-2016 browsers), the zone loses background, radius and text colors but the effect itself works.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="custom-cursor cursor-dot" id="cursorDot"></div>
<div class="custom-cursor cursor-ring" id="cursorRing"></div>
<div class="cursor-zone" id="zone-dot">
  Survolez cette zone
  <span class="info-text">Curseur point + anneau</span>
</div>
CSS
.custom-cursor {
  position: fixed;
  pointer-events: none;
  z-index: 9999;
  display: none
}

.cursor-dot {
  width: 8px;
  height: 8px;
  background: #6366f1;
  border-radius: 50%
}

.cursor-ring {
  width: 40px;
  height: 40px;
  border: 2px solid #6366f1;
  border-radius: 50%;
  transition: all .15s ease-out
}

.cursor-zone {
  /* Ces variables venaient du design system du SITE : chez l'acheteur
     elles n'existent pas, le fond disparaissait et les coins devenaient carres.
     Scopees ici, elles cascadent sans polluer sa page. */
  --bg-elevated: #1a1a24;
  --radius-xl: 1rem;
  --space-4: 1rem;
  --text-muted: #8b8b93;
  --text-secondary: #a1a1aa;

   width: 100%;
   height: 300px;
   background: var(--bg-elevated);
   border-radius: var(--radius-xl);
   display: flex;
   align-items: center;
   justify-content: center;
   font-weight: 600;
   color: var(--text-secondary);
   position: relative;
   overflow: hidden;
   }

.info-text  {
   position: absolute;
   bottom: var(--space-4);
   left: 50%;
   transform: translateX(-50%);
   font-size: 0.875rem;
   color: var(--text-muted);
   }

/* Le JS pose cette classe au survol d'une cible : sans regle, le curseur
   natif restait visible par-dessus le curseur personnalise. */
.cursor-active {
  cursor: none
}
JavaScript (fx-0197)
// Zone 1: Dot Follower
    (function(){
      const zoneDot = document.getElementById('zone-dot');
      if (!zoneDot) return;
      const cursorDot = document.getElementById('cursorDot');
      const cursorRing = document.getElementById('cursorRing');
      if (!cursorDot || !cursorRing) return;
      let ringX = 0, ringY = 0;
      let dotX = 0, dotY = 0;

      zoneDot.addEventListener('mouseenter', () => {
        zoneDot.classList.add('cursor-active');
        cursorDot.style.display = 'block';
        cursorRing.style.display = 'block';
      });
      zoneDot.addEventListener('mouseleave', () => {
        zoneDot.classList.remove('cursor-active');
        cursorDot.style.display = 'none';
        cursorRing.style.display = 'none';
      });
      zoneDot.addEventListener('mousemove', (e) => {
        dotX = e.clientX;
        dotY = e.clientY;
        cursorDot.style.left = dotX - 4 + 'px';
        cursorDot.style.top = dotY - 4 + 'px';
      });

      function animateRing() {
        ringX += (dotX - ringX) * 0.15;
        ringY += (dotY - ringY) * 0.15;
        cursorRing.style.left = ringX - 20 + 'px';
        cursorRing.style.top = ringY - 20 + 'px';
        requestAnimationFrame(animateRing);
      }
      animateRing();
    })();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Smoothing factor (JS — (dotX - ringX) * 0.15) 0.15 Share of the gap closed each frame. 0.3 = snappy ring (≤ 3 px in ~14 frames); 0.08 = long trail. Counted in frames: twice as slow in milliseconds at 60 Hz as at 120 Hz.
Ring size (CSS — .cursor-ring 40px) + offset (JS — ringX - 20) 40 px / −20 The two values are coupled: the offset must stay half the size for the ring to be centered. 56 px ⇒ ringX - 28.
Dot size (CSS — .cursor-dot 8px) + offset (JS — dotX - 4) 8 px / −4 Same coupling. 12 px ⇒ dotX - 6. Above 12 px the dot hides what it hovers.
Color (CSS — .cursor-dot background, .cursor-ring border) #6366f1 Dot and ring are independent: a solid white dot plus an accent-colored ring is a combination that reads on any background.
transition (CSS — .cursor-ring) all .15s ease-out Doubles the measured catch-up time (62 frames instead of 30). Remove it for a ring driven only by the loop, or restrict it to width, height, border-color if you add states.
Initial position (JS — let ringX = 0, ringY = 0) 0, 0 Cause of the fly-in from the top-left corner on first entry. In mouseenter, add ringX = e.clientX; ringY = e.clientY — the ring then appears right under the pointer.
z-index (CSS — .custom-cursor) 9999 Cursor stacking level. Lower it below your modals if the cursor must not float over them, or hide it when a dialog opens.

FAQ

Why does the ring arrive from the corner of the window the first time?

Because ringX and ringY are initialized to 0 and the loop is already running: on the first mouseenter, the ring shows up where the loop left it, at (0, 0), then approaches the pointer at 15% per frame — measured still 17 px away 30 frames after entry. The fix is one line in mouseenter: ringX = e.clientX; ringY = e.clientY;. That is exactly what Cursor Expand (fx-0209) does.

Where should the two cursor divs go in my page?

Right before </body>, outside any container. position: fixed with left = clientX assumes the window is the reference; if an ancestor has transform, filter, perspective, will-change: transform or contain: paint, it becomes the containing block and the cursor shifts by that ancestor's position — typically a dot appearing a few hundred pixels too low. The shipped code places the two divs right before the demo zone, as its siblings: move them to the root when integrating.

What does the never-ending requestAnimationFrame loop cost?

Measured: 121 calls per second at 120 Hz, cursor hidden, with two left/top writes per frame — a style and position recalculation for an out-of-flow element, nearly free on its own, but useless 99% of the time and multiplied by the number of instances. Start animateRing() in mouseenter and store the id returned by requestAnimationFrame to cancel it with cancelAnimationFrame in mouseleave. To go further, write transform: translate3d(x, y, 0) instead of left/top: the movement stays on the compositor, with no relayout.