Back to Blog
Building a Custom Cursor with Vanilla CSS & JavaScript (No React)
A production-safe, framework-agnostic guide to building a clean editorial custom cursor using plain CSS and JavaScript—no React, no dependencies.
Back
Frontend

Table of contents
If you want a cursor that feels Swiss editorial—restrained, legible, and quietly alive—a small dot paired with a trailing ring is a proven pattern.
This article shows how to build that effect using plain CSS and vanilla JavaScript. No React, no framework assumptions, and no hydration overhead. Just DOM, requestAnimationFrame, and a few hard-earned production constraints.
The result:
- A dot that sticks close to the pointer
- A ring that trails behind with subtle inertia
- A larger ring on interactive hover (links, buttons, inputs)
- Automatic opt-out for:
- touch devices / coarse pointers
- users who prefer reduced motion
Try it live: View Demo | Download HTML (right-click → Save As)
What We’re Optimizing For
Before touching code, it’s worth stating the goals explicitly:
- Zero layout thrashing
- No impact on accessibility defaults
- Predictable cleanup
- Safe for production use
Custom cursors fail when they ignore these constraints.
1) CSS: Cursor Visuals (Minimal & Defensive)
Add the following to your global stylesheet. Sizes and opacity are intentionally conservative.
css
@media (hover: hover) and (pointer: fine) {
html.has-custom-cursor,
html.has-custom-cursor * {
cursor: none !important;
}
}
.custom-cursor-dot {
position: fixed;
top: 0;
left: 0;
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgba(26, 26, 26, 0.75);
margin-left: -3px;
margin-top: -3px;
pointer-events: none;
z-index: 121;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.custom-cursor-ring {
position: fixed;
top: 0;
left: 0;
width: 18px;
height: 18px;
border-radius: 9999px;
border: 1px solid rgba(26, 26, 26, 0.55);
background: rgba(245, 243, 232, 0.03);
margin-left: -9px;
margin-top: -9px;
pointer-events: none;
z-index: 120;
transform: translate3d(0, 0, 0);
will-change: transform, width, height;
transition:
width 160ms ease,
height 160ms ease,
margin 160ms ease,
background-color 160ms ease,
border-color 160ms ease;
}Why border-radius: 9999px?
The 9999px value is a common CSS technique to create perfect circles (or pill shapes). Here's how it works:
border-radiusis automatically clamped to half the element's smallest dimension- For a 6px × 6px dot, the maximum radius is 3px
- For an 18px × 18px ring, the maximum radius is 9px
- Using
9999px(or any large value) ensures the browser applies the maximum possible radius
Why not use 50% or calculate the exact value?
border-radius: 50%works, but9999pxis more explicit and widely used in modern CSS- It's easier to maintain—no need to recalculate if you change element sizes
- This pattern is used by Tailwind CSS (
rounded-full=9999px) and other frameworks - It's a well-understood convention in the CSS community
Both approaches produce perfect circles, but 9999px is the pragmatic choice.
css
.custom-cursor-ring.is-interactive {
width: 30px;
height: 30px;
margin-left: -15px;
margin-top: -15px;
border-color: rgba(26, 26, 26, 0.32);
background: rgba(26, 26, 26, 0.05);
}Key points:
cursor: noneis gated behind media queriespointer-events: noneprevents interaction bugs- Only
transformis animated (GPU-friendly)
2) HTML: Mount Once
Place these elements once, ideally right before </body>:
html
<div class="custom-cursor-dot" aria-hidden="true"></div>
<div class="custom-cursor-ring" aria-hidden="true"></div>Nothing dynamic here—just two divs.
3) JavaScript: Pointer Tracking with Inertia
This version uses plain JavaScript, no framework lifecycle assumptions.
js
(function () {
const canUseCustomCursor = () => {
const finePointer = window.matchMedia(
'(hover: hover) and (pointer: fine)'
).matches;
const reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
return finePointer && !reducedMotion;
};
if (!canUseCustomCursor()) return;
const root = document.documentElement;
const dot = document.querySelector('.custom-cursor-dot');
const ring = document.querySelector('.custom-cursor-ring');
if (!dot || !ring) return;
root.classList.add('has-custom-cursor');
let mouseX = 0;
let mouseY = 0;
let dotX = 0;
let dotY = 0;
let ringX = 0;
let ringY = 0;
let rafId;
const onMouseMove = (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
};
const onMouseOver = (e) => {
const target = e.target;
const interactive =
target &&
target.closest(
'a, button, [role="button"], input, textarea, select, label, summary'
);
ring.classList.toggle('is-interactive', Boolean(interactive));
};
const animate = () => {
dotX += (mouseX - dotX) * 0.45;
dotY += (mouseY - dotY) * 0.45;
dot.style.transform = `translate3d(${dotX}px, ${dotY}px, 0)`;
ringX += (mouseX - ringX) * 0.18;
ringY += (mouseY - ringY) * 0.18;
ring.style.transform = `translate3d(${ringX}px, ${ringY}px, 0)`;
rafId = requestAnimationFrame(animate);
};
document.addEventListener('mousemove', onMouseMove, { passive: true });
document.addEventListener('mouseover', onMouseOver, { passive: true });
animate();
window.addEventListener('beforeunload', () => {
cancelAnimationFrame(rafId);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseover', onMouseOver);
});
})();Why This Works Well in Production
- No retained closures from framework hooks
- No memory leaks across page transitions
- Explicit opt-out for motion-sensitive users
- Easy to debug with DevTools
Common Pitfalls (Learned the Hard Way)
- Forgetting reduced-motion checks → accessibility regression
- Animating top/left → layout thrashing
- Leaving cursor enabled on touch devices → broken UX
- Global mutable state in SPAs → ghost cursors
This implementation avoids all of the above.
Try It Yourself
A complete, standalone HTML file is available for you to test and customize:
- View Live Demo: See the cursor in action
- Download HTML File: Right-click and "Save As" to get the complete code
The demo file contains all the CSS and JavaScript from this article in a single, self-contained HTML file. You can open it directly in your browser or use it as a starting point for your own implementation.
Final Notes
A custom cursor should never draw attention to itself. If users notice it, you've gone too far.
CSS defines the aesthetic. JavaScript defines the behavior. When both are minimal and well-scoped, you get motion that supports the interface instead of fighting it.
No framework required.
Comments
No comments yet
Loading comments...