Ardent
Identities, websites and films for brands that dare.Move the mouse across the scene
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.
This effect is free — the Cursors category contains 26 effects total, including 1 free. Effect.Labs has 811 vanilla effects. Explore the category →
Ardent
Identities, websites and films for brands that dare.Move the mouse across the scene
Move the mouse across the grid
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
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.
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.#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.cursor-active class stays even after a tap outside the zone (no mouseleave). Gate the initialization on matchMedia('(hover: hover) and (pointer: fine)').matches.@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.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.Requires ES2015 (const/let, arrow functions), classList, requestAnimationFrame and the CSS variables read by the zone. Zero dependencies.
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.
Copy the three blocks into your page. No dependencies.
<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>
.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
}
// 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();
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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. |
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.
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.
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.