A production-safe, framework-agnostic guide to building a clean editorial custom cursor using plain CSS and JavaScript—no React, no dependencies.
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:
Try it live: View Demo | Download HTML (right-click → Save As)
Before touching code, it’s worth stating the goals explicitly:
Custom cursors fail when they ignore these constraints.
Add the following to your global stylesheet. Sizes and opacity are intentionally conservative.
@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 dimension9999px (or any large value) ensures the browser applies the maximum possible radiusWhy not use 50% or calculate the exact value?
border-radius: 50% works, but 9999px is more explicit and widely used in modern CSSrounded-full = 9999px) and other frameworksBoth approaches produce perfect circles, but 9999px is the pragmatic choice.
.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 queriespointer-events: none prevents interaction bugstransform is animated (GPU-friendly)Place these elements once, ideally right before </body>:
<div class="custom-cursor-dot" aria-hidden="true"></div><div class="custom-cursor-ring" aria-hidden="true"></div>Nothing dynamic here—just two divs.
This version uses plain JavaScript, no framework lifecycle assumptions.
(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); });})();This implementation avoids all of the above.
A complete, standalone HTML file is available for you to test and customize:
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.
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.
No comments yet
Loading comments...