Optimise Javascript cursor so it runs smoother (mainly Safari)

I have a custom Javascript cursor that doesn’t run as smoothly in Safari as Chrome (mac OS). Firefox seems ok but the cursor performs great in Chrome. This is for a portfolio/interactive example so aware hiding cursors is not best practice - but fine is this instance.

Main goals/questions are:

  • Can this be optimised to improve performance (smoothness) - mainly noticed in Firefox/Safari
  • Occasionally the default cursor (pointer) is displayed even though I’ve set * { cursor: none !important;} - is there a better way of doing this?
  • I hide the cursor on touch devices. Ideally the script could stop running?

CodePen Example: https://codepen.io/moy/pen/popxXXe

You will need to preview on a tall/wide viewport as the issue affects desktop views.

The desired effect is achieved with the above code but in Safari I’m noticing lag/stuttering performance as well as the cursor momentarily stopping when the image of the carousel changes. This is when the cursor is not over the carousel (left half of screen), as the carousel pauses when the cursor is over it.

This seems more noticeable on my build than the CodePen unfortunately but the code does match. The size of the images could maybe be a factor. However this does happen after all the images have been loaded and page refreshed.

Performance in Safari does seem to improve when changing the Timeout value …but then it gets worse in Firefox.

All my browser testing is mac OS (Chrome/Firefox/Safari) so far.

Thanks in advance, hope someone can lend some support :slight_smile:

1 Like

if you don’t mind doing some research yourself (it’s my beddy byes time), I highly recommend that you look in to throttling mousemove so that it only fires 4 or 5 times per second, instead of hundreds.

Following on from Paul’s suggestion, you could use lodash’s throttle function.

A CDN for just the throttle and debounce functions can be found here

e.g.

<script src="https://cdn.jsdelivr.net/npm/@mig8447/lodash-debounce-throttle@4.17.5/dist/lodash-debounce-throttle.min.js"></script>

Using the stackoverflow mousemove post as an example, the code can then be re-written as follows

const logMousePosition = function(event) {
  console.log(`Mouse position (${event.pageX}, ${event.pageY})`)
}

$(document).on('mousemove', _.throttle(logMousePosition, 200)) // every 200ms

Example
https://codepen.io/rpg2019/pen/OJQomeb

btw, the codepen demo runs very smooth on my Safari…

It’s very laggy on my Safari though although my os is a bit old.

Thanks for all the replies! As you can probably tell JS is not my forte so it’s a bit cobbled together from stuff I’ve found and added/extended upon so could definitely be refactored - might look at getting a freelancer if it’s beyond me.

Throttling sounds interesting, thanks for that. I don’t currently use jQuery (although could include if it solves the issue) in the theme, so I’ll try to search for a vanilla solution if possible - but will checkout lodash’s throttle!

About hover styling being in CSS - I think due to how I change the cursors appearance depending on what it’s hovering over at the time, I don’t think I can achieve the same with CSS as there are no ‘hover’ states on the div’s that emulate the cursor?

@moymadethis

I have been having a bit of a play and I have to say it has been a learning exercise for me too.

I have opted to use throttle with a delay of 40ms, rough thinking was 25fps, but I’m guessing that isn’t necessarily how it works. I have also used transform translate instead of left and top.

In addition have opted to add a mouseover listener to the body and use event delegation instead to check if the target element is an anchor link — rather than adding listeners to each link.

It’s only a rough and maybe not quite the effect you were looking for — a work in progress.

Demo here
https://codepen.io/rpg2019/pen/KKQGwLp

Whether this addresses the lag issue on the final page with slider, I am not sure yet :slight_smile:

1 Like

@moymadethis

Right here is a test with your page including the slider. Not quite as snazzy as yours, and missing the radius calculations, but that wasn’t my focus.

Cursor Javascript

const moveCursor = function (event) {
  const mouseY = event.clientY;
  const mouseX = event.clientX;

  requestAnimationFrame(function(event) {
    cursor.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
  })
}

const hoverHandler = function (event) {
  const entered = event.type === 'mouseenter';
  const className = event.target.dataset.interaction || 'a-hover'

  cursor.classList.toggle(className, entered)
}

const cursor = document.querySelector('.cursor');
const links = document.querySelectorAll('a');

document.body.addEventListener('mousemove', _.throttle(moveCursor, 40))

links.forEach(function(link) {
  link.addEventListener('mouseenter', hoverHandler)
  link.addEventListener('mouseleave', hoverHandler)
})

https://codepen.io/rpg2019/pen/abqaKdE

I scrapped the earlier idea of using mouseover and event delegation for the links in favour of mouseenter and mouseleave. Reading MDN’s page on mouseenter explains the benefits.

A single mouseover event is sent to the deepest element of the DOM tree, then it bubbles up the hierarchy until it is canceled by a handler or reaches the root.

With deep hierarchies, the number of mouseover events sent can be quite huge and cause significant performance problems. In such cases, it is better to listen for mouseenter events.

Combined with the corresponding mouseleave (which is fired at the element when the mouse exits its content area), the mouseenter event acts in a very similar way to the CSS :hover pseudo-class.

I don’t have Safari to test unfortunately. I have tested in chrome, firefox, opera and edge and the cursor movement is smooth.

@PaulOB There is one issue though and only in chrome, where on moving the cursor there is a visible clipping effect happening. backface-visibility didn’t resolve this. Any ideas? :slight_smile:

edit: I added a sizeable padding to the wrapping cursor element, which has hidden that clipping. Seems a bit hacky though.

2 Likes

You added a value of ‘none’ which is not a valid value. I think you meant hidden.

.cursor {
  position: fixed;
  width: 100px; // was 35px
  height: 100px; // ditto
  padding: 30px; // padding added
  z-index: 1000;
  transition: transform .1s linear;
  pointer-events: none;
  backface-visibility: hidden;/* not none*/

That is the usual fix for artefacts in Chrome but doesn’t always work. Sometimes triggering the 3d rendering can help but it’s very hit and miss. Your padding solution seems fine.:slight_smile:

That’s better in my Safari than the original.

Although I am not keen on either effect :frowning:

2 Likes

Thanks Paul :slight_smile:

2 Likes

Thanks for all this!

As the effect is quite important to me, I wonder if I just need to accept maybe Safari doesn’t handle this too well? It’s obviously an issue with the lag/trailing div, right?

Part of me wonders if it’s because I’m using transition on quite a few things and maybe it’s a lot for the browser to handle as well? I’m sure I read Safari struggles with transitioning/animating certain declarations but I can’t find the doc right now!

This was what I based my code on by the way - but this had even bigger issues in Safari: https://codepen.io/ntenebruso/pen/QWLzVjY

The App Store has several Safari extensions categorised as Safari custom cursors. You may not have to reinvent the wheel.

@moymadethis did you try out the optimised codepen I provided. Unfortunately I don’t have a Mac, so have been unable to test it in Safari.

I know @PaulOB said that it was better, but I appreciate that doesn’t necessarily mean good or good enough.

Two thoughts spring to mind. One you can experiment with the throttle delay and the css transition duration timings on the cursor to see if you can find a sweet spot — that seems a bit flakey though.

Or you feature test and provide a somewhat cutback version for Safari. I know you can make custom cursors using css and images instead. Taken from freeCodeCamp How to make a custom cursor, here is a codepen css only version. Not as dynamic, but maybe a fallback for Safari?

Edit: Another link for you to check out.

Thinking about it, the cut back approach might actually be a better idea all round and keep your site lightweight — less is more. I also wonder whether these somewhat hacky approaches have a negative effect on accessibility.

2 Likes

Ok in that pen the delay is caused by using calc() in the js to work out the offset and if you hard code the width it works much much quicker in my safari and doesn’t jiggle around.

e.g.

/*
document.addEventListener('mousemove', function(e){
  var x = e.clientX;
  var y = e.clientY;
  cursor.style.transform = `translate3d(calc(${e.clientX}px - 50%), calc(${e.clientY}px - 50%), 0)`
});
*/
document.addEventListener("mousemove", function (e) {
  var x = e.clientX;
  var y = e.clientY;
    cursor.style.transform = `translate3d(${e.clientX - 25}px, ${e.clientY -25}px, 0)`;

});

In your page you have used calc in the same routines but there is no need because you are subtracting a known amount and not using calc to convert from percent to px etc.

Therefore you don’t need calc.

e.g.

/*
  cursor.style.left = `calc(${e.clientX}px - ${radiusOfCursor}px)`;
  cursor.style.top = `calc(${e.clientY}px - ${radiusOfCursor}px)`; 
  */  
 cursor.style.left = `${e.clientX - radiusOfCursor}px`;
 cursor.style.top =  `${e.clientY - radiusOfCursor}px`;

And the same for here;

function mouseMoveEnd() {
  cursor.style.left = `${cursorinner.style.left - radiusOfCursor}px`;
  cursor.style.top =  `${cursorinner.style.top - radiusOfCursor}px`;
}

That is immediately quicker in my Safari although still not as good as other browsers but much more usable.

2 Likes

Looks like someone already solved this from a question on SO last year (it looks to be the same routine).

That fiddle is very smooth in my Safari.

2 Likes

It seems the main trick was to throttle the outer ring but let the inner dot move immediately.

This cut down version works smoothly in my Safari.

2 Likes

I had a go at this myself last night. With mine both inner and outer are handled with a throttled function and their positions are set/transformed to the same position. The difference is that the outer ring has a slightly longer transition delay set in css.

Edit: (Just spent 5 minutes dragging a dot and circle around the screen, procrastinating again)

2 Likes

Ha ha. :slight_smile:

Unfortunately in my older safari that demo above is quite jerky. I actually get two middle buttons showing (about 50px apart) as you move across the screen and the bigger circle stutters along afterwards. There is no smooth animation although it doesn’t get worse the faster you move unlike the original.

In my demo they both move smoothly across the screen when moving fast or slow (although I know the other routines aren’t present yet).

Note that the OPs original demo is also much quicker in Safari when making the changes already mentioned (removing calc) and changing to a transform instead of the top left approach.

My Mac is 10:13:6 High sierra which is the latest the hardware will support and will only run Safari Version 13.1.2 (13609.3.5.1.5). If newer Safari does not have the same issue then I guess it’s not really a major problem but Macs do seem to last a lot longer than windows computers so there may still be quite a few users like me.:slight_smile:

1 Like

Kind of working blind as far as Safari, but that is good to know Paul.

Just out of interest if you change the throttle in mine down to zero, how is it then? Still bad?

document.body.addEventListener('mousemove', _.throttle(moveCursor, 0))

Yes you still get the doubling effect at zero. However if you change it to around 66 then the double goes away and is much smoother but not as smooth as in my reduced demo.

If you move the pointer out of the requestAnimation frame it becomes smoother stil…

  const moveCursor = function (event) {
    const mouseY = event.clientY - 45;
    const mouseX = event.clientX - 45;
 pointer.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
    requestAnimationFrame(function(event) {
      circle.style.transform = `translate(${mouseX}px, ${mouseY}px)`;    
    })
  }

Although I guess that may slow other routines down but on its own it’s much smoother.

1 Like