JavaScript-Driven Cursor Styling: Beyond Basic CSS

Most websites leave the cursor alone — and rightly so. Users expect a consistent pointer, and touch interfaces make custom cursors invisible to a large portion of the audience. But for those times when a bespoke cursor genuinely enhances an experience, CSS only takes you so far.

JavaScript opens up a different class of cursor design: instead of swapping in a static image, you can repurpose an entire DOM element as your pointer. That means transitions between states, dynamic text, complex animations, and CSS filters all become available for cursor styling.

Building a Custom Cursor Element

The core idea is straightforward: create a div and keep it glued to the mouse position. A mousemove event listener handles the tracking, and you can layer on effects — such as scaling on click — with a mousedown listener.

There are two critical CSS rules to get right. First, hide the native pointer globally with:

* { cursor: none; }

Note that some browsers will still display the native cursor if the document doesn't fill 100% of the viewport height. Second, your custom cursor element needs pointer-events: none so it doesn't intercept clicks and hovers on the content beneath it. You can then trigger visual feedback — like a size change — by toggling a class when hovering over interactive elements.

Handling Fallbacks and Accessibility

The obvious first concern is touchscreen users, who have no cursor at all. A second is users who set the prefers-reduced-motion preference, where a constantly moving custom cursor can be distracting or disorienting. Both can be checked in JavaScript before enabling the custom cursor — if either condition is true, simply skip the custom-cursor logic entirely.

This JavaScript-gated approach also provides a natural fallback: if JavaScript is disabled, users get the browser's default cursor behavior.

But you can do better than the default. A CSS-only fallback — set via the standard cursor property — can approximate your styled cursor when scripting isn't available. For instance, a small base64-encoded circle image with:

cursor: url(data:image/png;base64,...) 16 16, auto;

The 16 16 values position the hotspot at the circle's center.

const isTouchDevice = "ontouchstart"in window || navigator.maxTouchPoints > 0;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!isTouchDevice && !prefersReducedMotion && cursor) {
  // Cursor implementation is here
}
html {
  cursor:
    url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0Jve
D0iMCAwIDMyIDMyIj4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNiIgZmlsbD0iYmxhY2siIC8+Cjwvc3ZnPg==")
    16 16,
    auto;
}

The progressive enhancement chain looks like this: no CSS, no JS — browser default; CSS but no JS — fallback image; JS enabled and touch/reduced-motion preferences absent — full custom element.

Inspiration and Caution

Once you have the basic mechanics down, the design space is wide open: invert content under the cursor with a filter, animate the element, make it lag behind the pointer, or reform it entirely. Notable implementations include:

A word of restraint: overriding native OS and browser affordances removes a layer of predictability for users. Replace the cursor only when it clearly serves the interface, and ensure the experience degrades gracefully for those who can't or don't use it.