Dynamic Custom Cursors - JS + CSS


Using custom cursors in browsers isn't new or complicated. There are already extremely well-written articles on how to do this with static PNGs, for instance this one from David Walsh Blog. However, I recently ran into a situation that added a bit of a different twist to the notion of custom cursors and thought I'd share one solution I implemented.


The Scenario

I'm building a ui-widget for an application that is a web-based epub reader. This widget allows users to draw on a canvas overlaying the epub pages, but the colors are configurable so each epub could specify different colors rather than using the defaults. Nothing too complicated yet, but the twist comes in when the requirements state that we'll need to provide a custom cursor matching the selected color!


The Solution

We really don't want to have to create a billion png files for all the possible colors and have the at the ready, nor do we want to force consumers of the app to provide them either. So, enter SVG template turned into a data-uri, and using JS to dynamically insert style rules as needed!


CSSStyleSheet

The first thing we need to do is to create a new style element to gain access to its CSSStyleSheet. Then we can manipulate its CSSRule entries. We could just grab an existing style element from the page, but I prefer creating a separate one so we don't accidentally modify/remove rules from another StyleSheet. Also, in some browsers, you will run into issues trying to access rules from a sheet from another domain than the page.

// create the style element
let style = document.createElement('style');
style.type = 'text/css';
style.id = 'customCursorStyles';
document.head.appendChild(style);

// access its CSSStyleSheet
let customCursorStyleSheet = style.sheet;

Manipulating Rules

The parts of the CSSStyleSheet API that we'll be using are really simple. We'll work with the insertRule and deleteRule methods.

To insert a rule, we need two things:

  1. CSSText - this is a string of the rule we want to insert. This contains the entire rule - selector and properties. i.e. 'h1 { font-weight: 800; }'
  2. index - this is the number defining where in the list of rules this new one should go. It is mandatory; there will be an error if it is not provided.

To delete a rule, we only need the index of the rule we want to delete.

To examine existing rules, we can simply access the CSSStyleSheet's cssRules property. They are a collection, even if only one rule is defined, and each CSSRule exposes a pretty simple api - but for this post we're just going to use the CSSText property. Here are some examples:

//retrieve our custom style element and access its sheet
let sheet = document.getElementById('customCursorStyles').sheet;

// add rule to end of sheet
sheet.insertRule(
    'h1 { font-weight: bold; color: black; }',
    sheet.cssRules.length
);

// remove rule at a specific index
sheet.removeRule(0);

// iterate through rules and remove those matching a specific selector
// iterate in reverse, as deleting items when iterating forwards can throw off indexes
for (let i = sheet.cssRules.length - 1; i >= 0; i--) {
    let rule = sheet.cssRules[i];
    if (/^h1\s?{/.test(rule.cssText)) {
        sheet.deleteRule(i);
    }
}


The SVG Template

Now that we know how to work with CSS Styles in our javascript, we need a template to modify as needed and then create a data-uri from. We'll use SVG for this, as it easily allows us to add interpolation points into the template (i.e. instead of specifying a specific fill, we could do fill="{{fillColor}}" in the SVG. Let's take a simple SVG of a bullseye where we want the inner circle to be colored based on user input.

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50px" height="50px">
<g>
  <circle cx="22" cy="22" stroke="black" style="stroke-width: 1px;" r="20" fill="blue"/>
  <circle cx="22" cy="22" stroke="black" style="stroke-width: 1px;" r="15" fill="yellow"/>
  <circle cx="22" cy="22" stroke="black" style="stroke-width: 1px;" r="10" fill="white"/></g>
</svg>

Putting It Together

Now we simply have to populate the SVG template's interpolation point fill="{{bullseyeFillColor}}" with an actual color, and then we can convert the SVG template to a data-uri and add the appropriate css rule!

// i.e. could use webpack, etc... to import the SVG text
import svgTemplate from 'bullseye.svg';
const selector = '.bullseye';

// get ref to the stylesheet
let sheet = document.getElementById('customCursorStyles').sheet;

// a function that we can call to change bullseye cursor color
export function changeBullseyeColor(newColor) {
  let svg = svgTemplate.replace('{{bullseyeFillColor}}', newColor);
  // clear out any existing rules for the selector(s) this module would manage, if desired, using methods shown previously
  // convert svg to data-uri
  svg = encodeURIComponent(svg.replace(/"/g, "'"));
  let svgRule = `${selector} { cursor: url("data:image/svg+xml,${svg}"), auto; }`;
  sheet.insertRule(svgRule, sheet.cssRules.length);
}

Quick Demo

Let's take our bullseye svg and the HTML5 color picker <input type="color"> and see how this works. Select the color you want, then hover over the silver box below!

NOTE HTML5 Color Input doesn't work in IE, but neither do custom cursors without using .cur files so I'm not concerned with that for this pot :p Safari also doesn't support it. In these cases, just type in a valid hex or named-color (i.e. red, black, blue) value as I'm not checking if the data is a valid color in the demo.


The Catch(es)

So, as with everything in browser land, there are some catches to this.

First off, as mentioned earlier, this won't work in IE or Edge. Neither support data-uri for cursors, and both require the use of .cur files to do custom cursors. Needing super dynamic cursors in those browsers? May not be possible :(

Secondly, SVG cursors show up kind of blurry on high-res devices! There are already open bugs about this, such as this one, so it's a known issue. But, it's best to know these things before putting the time into implementing them!!


Have a better solution, or an improvement to this one? I'd love to hear about it! Leave a comment below or hit me up on twitter!

-Bradley