A cover image alt
April 8, 2023 4 min read

Improved Live Cursor with Phoenix

How to show a lag-free live cursor in your Phoenix app


Overview

This is a follow up to Koen van Gilst’s great blog post Phoenix LiveView Cursors which walks you through the process of adding cursors for the visitors on page using LiveView and Phoenix Presence. Be sure to read it before continuing here, this builds upon what you’ll make there.

The tutorial does a great job at delivering a working feature and teaching you along the way, but it felt like one additional step in the journey was missing — improving the local users mouse responsiveness.

The code for this post is available on GitHub with each commit representing a step in the process.

The Problem

With the implementation at the end of Koen’s tutorial we end up with all users sending their position to the server and the LiveView rendering each mouse with a custom SVG, their name, and chat message.

The ping pong of sending your mouse position to the server and waiting for it to be processed and sent back to you takes time.

It’s perfectly acceptable for the other users cursors to be slightly behind their actual position, but when applied to your own cursor the slight lag leaves you feeling like your mouse is covered in peanut butter.

mouse lag

We can make the issue clear by adding 100ms of simulated latency to the LiveView. By rendering both the default local cursor along with your LiveView synced cursor we can see that the networked cursor lags behind the actual mouse position.

In the final app the default cursor is hidden and you can only see the LiveView synced cursor, so with enough latency this sluggishness renders the mouse nearly unusable.

Solutions

One simple solution is simply to only render other users cursors using LiveView, leaving your users native cursor untouched. In this example, as the cursors look dramatically different and have a name/text floating next to them, this would not be an option.

In this solution we render the users cursor independently of the other networked cursors, instead relying on a bit of client side JavaScript and the glory of CSS variables to modify the rendered cursors position.

Pushing the remote mouse position

The heart of the cursor tracking solution presented in Koen’s post relies on tracking the mousemove event in a JavaScript hook, pushing the mouses position to the server:

document.addEventListener('mousemove', (e) => {
const x = (e.pageX / window.innerWidth) * 100; // in %
const y = (e.pageY / window.innerHeight) * 100; // in %
this.pushEvent('cursor-move', { x, y });
});

Storing our local mouse position

In order to control our local cursor from the client side we save our mouse position into two new CSS variables, --x and --y.

document.documentElement.style.setProperty("--x", `${x}%`);
document.documentElement.style.setProperty("--y", `${y}%`);

The beauty of CSS variables is that they can be referenced in our cursor positions, allowing us to use the latest and greatest coordinates when rendering our local cursor!

Rendering our local cursor

In the tutorial example we loop through each user in our assigns and render their custom cursor.

<%= for user <- @users do %>
<li style={"left: #{user.x}%; top: #{user.y}%"}
..
>
..

When rendering the cursors we can check if the cursor is the local user and ignore the x and y positions sent by the server, instead using our local --x and --y CSS variables as our coordinates to use the absolute latest mouse position.

<% is_local_user = user.socket_id == @socket.id %>
<% x = if is_local_user, do: "var(--x)", else: "#{user.x}%" %>
<% y = if is_local_user, do: "var(--y)", else: "#{user.y}%" %>
<li style={"left: #{x}; top: #{y}"}
..
>

mouse fast

Voila! Now our local mouse is being tracked in real time, even with 100ms of simulated latency.

Nice.

Now all we need to do is put our cursor-none back in to hide our default cursor and we’ve got a custom, multi-user, LiveView mouse that still feels perfectly responsive!

Remaining Issues

We may have solved The Case of the Cumbersome Comatose Cursor but we’re not done yet!

The mouse position is based on the percentage across the screen it’s at — and that isn’t going to be the same for all users. Some users will have their window narrow and others may be viewing it full screen on an ultra-wide monitor! Unless users have their browser at the exact same size, there are going to be inconsistencies on where other users mice appear versus reality.

That won’t do.

Next time we’ll work on changing our mouse position logic to be independent of screen size!

Example project

If you’d like to take this code for a spin grab the example project and try it out!

Learn more



Headshot of Matt Furden

Hi! I'm Matt, a software engineer and artist based in the Central Coast of California. You can chat with me on Discord, see some of my work on GitHub or Instagram.

@/components/ui/button@/utils/open-graph