1. Home
  2. /Blog
  3. /Building a Custom Cursor with Vanilla CSS & JavaScript (No React)
2026-01-153 min readLoading views...Frontend

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.

CSSJavaScriptFrontendCursorVanilla JS

Building a Custom Cursor with Vanilla CSS & JavaScript (No React)

2026-01-153 min readFrontend
Building a Custom Cursor with Vanilla CSS & JavaScript (No React)
Table of contents
What We’re Optimizing For1) CSS: Cursor Visuals (Minimal & Defensive)2) HTML: Mount Once3) JavaScript: Pointer Tracking with InertiaWhy This Works Well in ProductionCommon Pitfalls (Learned the Hard Way)Try It YourselfFinal Notes

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-radius is 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, but 9999px is 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: none is gated behind media queries
  • pointer-events: none prevents interaction bugs
  • Only transform is 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...

Table of contents
What We’re Optimizing For1) CSS: Cursor Visuals (Minimal & Defensive)2) HTML: Mount Once3) JavaScript: Pointer Tracking with InertiaWhy This Works Well in ProductionCommon Pitfalls (Learned the Hard Way)Try It YourselfFinal Notes
or search for other articles
Previous

Basic Custom Cursor Using CSS Only (No JavaScript)

2026-01-15Frontend
Next

PHP in 2026: Still King of the Web?

Backend2026-01-15

Let's Talk.

LinkedInGitHubTwitter

© 2024 idnasirasira.

Designed & Engineered with ♥ in Jakarta.