Cursor effect
Hide the native cursor safely: a scoped CSS and JS pattern
This pattern hides the system cursor only inside the sections you choose, draws a dot exactly where the pointer is, and hands the real cursor back wherever hiding it would hurt.
Usage
When to use it
- Hero sections, galleries and showcases where a custom cursor is the point of the design.
- Interactive canvases and games that draw their own pointer.
- As the safe base for any custom follower, instead of a page-wide cursor: none.
- If you are not sure you need it, keep the native cursor and use the dot-ring follower on top of it.
Code
Copy the effect
<section class="cursor-zone">
<h2>The system cursor is hidden here</h2>
<p><a href="#">Links grow the dot into a ring</a></p>
<label>The real I-beam comes back in fields <input type="text"></label>
</section>
<div class="cursor-replacement" aria-hidden="true"></div> .cursor-replacement {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
box-sizing: border-box;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #2d5bff;
box-shadow: 0 0 0 2px #fff;
pointer-events: none;
opacity: 0;
}
.cursor-replacement.is-visible {
opacity: 1;
}
.cursor-replacement.is-link {
width: 32px;
height: 32px;
margin: -16px 0 0 -16px;
border: 2px solid #2d5bff;
background: transparent;
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
.cursor-zone.is-active,
.cursor-zone.is-active * {
cursor: none;
}
.cursor-zone.is-active :is(input, textarea, select, [contenteditable="true"], [contenteditable="true"] *) {
cursor: auto;
}
} (() => {
const dot = document.querySelector('.cursor-replacement');
const zones = document.querySelectorAll('.cursor-zone');
const allowed = window.matchMedia('(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)');
if (!dot || !zones.length) return;
const FIELDS = 'input, textarea, select, [contenteditable="true"]';
const CLICKABLE = 'a, button, [role="button"], label, summary';
function hideDot() {
dot.classList.remove('is-visible', 'is-link');
}
function deactivate(zone) {
zone.classList.remove('is-active');
hideDot();
}
zones.forEach((zone) => {
zone.addEventListener('pointermove', (event) => {
if (event.pointerType !== 'mouse' || !allowed.matches) {
deactivate(zone);
return;
}
const target = event.target instanceof Element ? event.target : zone;
const onField = Boolean(target.closest(FIELDS));
zone.classList.add('is-active');
dot.classList.toggle('is-visible', !onField);
dot.classList.toggle('is-link', !onField && Boolean(target.closest(CLICKABLE)));
dot.style.transform = `translate3d(${event.clientX}px, ${event.clientY}px, 0)`;
}, { passive: true });
zone.addEventListener('pointerleave', () => deactivate(zone));
});
allowed.addEventListener('change', () => zones.forEach(deactivate));
window.addEventListener('blur', hideDot);
})(); Accessibility
Motion, touch and screen readers
Reduced motion
The rule that hides the cursor sits inside @media (prefers-reduced-motion: no-preference), and the script checks the same media query before it hides anything. With reduced motion on, the system cursor stays and the dot never appears. The dot itself follows the pointer exactly, with no easing or trailing.
Accessibility notes
- The cursor is only hidden after the script runs and the pointer is inside a zone. If the script fails, people keep the normal cursor.
- Form fields and editable areas get the system cursor back, and the dot hides there, so typing works exactly as usual.
- Touch screens and pens are left alone: the script reacts to the mouse only, and the CSS needs (hover: hover) and (pointer: fine).
- The replacement ignores the visitor's pointer size and colour settings. Keep zones small and avoid them on content people read for a long time.
- The dot has aria-hidden="true" and pointer-events: none, so it never blocks a click.
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. Give each section the class cursor-zone in the Designer and add the cursor-replacement div with an Embed element. Avoid setting cursor: none on the Body class in the Style panel; that hides it everywhere, including in forms.
Framer
Framer's Cursor settings have a Replace mode that hides the system cursor under a custom component. If you use it, test form fields and touch devices. For this exact pattern, paste the CSS and JS into custom code and mark sections with the class through an Embed or a code component.
React
Render the replacement div once at the root and run the script in a useEffect that removes every listener in its cleanup. If zones mount and unmount with routes, attach a single pointermove listener on document and check event.target.closest('.cursor-zone') instead of binding each zone.
FAQ
Questions
- Why not just put cursor: none on the body?
- Then every page and every form loses the system cursor, touch devices get the rule too, and a slow or failed script leaves people with no cursor at all. Scoping it avoids all of that.
- Why does the system cursor come back over text fields?
- So people can see the I-beam and place the caret precisely. A dot cannot show where the text insertion point will land.
- Can I add a trailing ring to this?
- Yes, but keep the dot exact. Put any easing on a second element, as in the dot-ring follower, so the true click point is always clear.