Table of contents
Displaying other users' cursors live on-screen has always been tricky to implement... but no longer! Using Liveblocks and Next.js we can get it working in a matter of minutes. Open this page in another window, side-by-side, to see it in action!
Open in new windowWhat is Liveblocks?
Liveblocks is a set of APIs and tools (released just two days ago!) built to assist with creating collaborative experiences, such as shared drawing tools, shared forms, and seeing live cursors. In the past I've created live cursors with a custom websocket server, and I cannot emphasise how much easier this is!To check out the working demo on this page, open this article in two browser windows, side-by-side, and try moving your cursor around. You should see something like the demo above!
Sign up & Install
First, we need to sign up to Liveblocks (side note: their website's design is magical). Their free plan allows for unlimited rooms, and up to 5,000 connections per month.
After verifying your email, hop into the Dashboard and make a note of your API key (it'll look similar to this):
sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
From this point you can follow along on CodeSandbox, if you don't have a project ready:
Install packages
Next we need to install the Liveblocks packages to our Next.js project:
npm install @liveblocks/client @liveblocks/react @liveblocks/node
And set the Next.js LIVEBLOCKS_SECRET_KEY
environment variable to the secret key:
LIVEBLOCKS_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Liveblocks Concepts
Liveblocks makes use of a couple of concepts that we must understand, the first being a room.
Room
A room, much like real life, is a single location where users can congregate. If you're using live cursors on multiple pages in your app, it makes sense to create a new room for each page. You can also create new rooms for the same page, when the initial room becomes too busy.
Presence
Presence refers to the movements and actions of people within the rooms. In the case of our demo, presence will refer to an object held by each user, containing the locations of their cursors on the page.
Authenticating
To connect to a Liveblocks room we need to build a simple API endpoint. This will allow us to connect without exposing our secret key.
Create a new file within /pages/api/
and name it auth.ts
:
import { authorize } from '@liveblocks/node'
const secret = process.env.LIVEBLOCKS_SECRET_KEY as string
// Connect to Liveblocks
export default async function auth (req, res) {
const room = req.body.room
const result = await authorize({ room, secret })
return res.status(result.status).end(result.body)
}
Create config
Liveblocks 0.17 brings improved TypeScript support,
and the best way to leverage this is to add all our types to a special config file, providing automatic typing to every React hook. We'll call this
file liveblocks.config.ts
and put it in the root of our project. We'll also be connecting to our API route from in here—don't worry, I'll explain about this below!
import { createClient } from '@liveblocks/client'
import { createRoomContext } from '@liveblocks/react'
// Connect to our API route
const client = createClient({
authEndpoint: '/api/auth'
})
// Define user presence
type Presence = {
cursor: { x: number, y: number } | null
}
// Pass client and Presence to createRoomContext & create React utilities
export const {
RoomProvider,
useUpdateMyPresence,
useOthers
} = createRoomContext<Presence>(client)
Let's break it down. createClient
's authEndpoint
property should point to the authentication route we created earlier:
const client = createClient({
authEndpoint: '/api/auth'
})
Next we'll define our types. This type is the presence we'll be using in our app; cursors will either be on screen (with x
and y
coordinates), or be off screen (null
). We can use any JSON-serialisable data in presence:
type Presence = {
cursor: { x: number, y: number } | null
}
We then pass client
and Presence
to createRoomContext
, and from here we can export every React hook we'll be using:
export const {
RoomProvider,
useUpdateMyPresence,
useOthers
/* Other React hooks */
} = createRoomContext<Presence>(client)
Every time you use a new React hook, remember to export it from here!
Cursor component
Now we can start developing our app. First up, we'll create a quick cursor component:
We'll be moving the cursor around using transform: translate()
, along with a transition
property to smooth
the animation between every Liveblocks update.
// Simple arrow shape
const CursorImage = () => <svg><path fill="currentColor" d="M8.482,0l8.482,20.36L8.322,17.412,0,20.36Z" transform="translate(11 22.57) rotate(-48)" /></svg>
// Give cursor absolute x/y positioning
export default function Cursor ({ x, y }: { x: number, y: number }) {
return (
<div style={{
color: 'black',
position: 'absolute',
transform: `translate(${x}px, ${y}px)`,
transition: 'transform 120ms linear'
}}>
<CursorImage />
</div>
)
}
We'll build the avatar cursors later, let's hook everything up first!
Rendering the cursors
The next step is to render the cursors to the screen. For this, we're going to create a new component named CursorPresence
. I'll
explain in detail below, but first, here's the code:
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'
import Cursor from './Cursor'
export default function CursorPresence ({ children }) {
const updateMyPresence = useUpdateMyPresence()
const onPointerMove = event => {
updateMyPresence({
cursor: {
x: Math.round(event.clientX),
y: Math.round(event.clientY)
}
})
}
const onPointerLeave = () => {
updateMyPresence({ cursor: null })
}
const others = useOthers()
const showOther = ({ connectionId, presence }) => {
if (!presence || !presence.cursor) {
return null
}
const { x, y } = presence.cursor
return (
<Cursor key={connectionId} x={x} y={y} />
)
}
return (
<div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
{others.map(showOther)}
{children}
</div>
)
}
This'll make sense in a minute! Let's take a look.
useUpdateMyPresence
useUpdateMyPresence()
is a hook that updates the current user's presence, and automagically sends out updates to
other connected users. We're using it within onPointerMove
to pass the current location of the cursor, and then
within onPointerLeave
to reset the location if the cursor leaves the <div>
.
// Update your cursor locations with this function
const updateMyPresence = useUpdateMyPresence()
// When your cursor moves, update your presence with its current location
const onPointerMove = event => {
updateMyPresence({
cursor: {
x: Math.round(event.clientX),
y: Math.round(event.clientY)
}
})
}
// If your cursor leaves the element, or window, set cursor to null
const onPointerLeave = () => {
updateMyPresence({ cursor: null })
}
// ...
return (
<div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
...
</div>
)
Now we're successfully updating our presence/cursor location!
useOthers
useOthers()
is a hook that returns an array of each user's presence (excluding yours). We can utilise this to display
other users' cursors. We'll create a function that renders the cursor to the page, so long as presence
and
presence.cursor
are set, and call this on each user's presence.
// ...
// Get other users' presence
const others = useOthers()
// Function to display a user's presence
const showOther = ({ connectionId, presence }) => {
// If presence or cursor null or undefined, don't display
if (!presence?.cursor) {
return null
}
// Display cursor
const { x, y } = presence.cursor
return (
<Cursor key={connectionId} x={x} y={y} />
)
}
return (
<div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
{others.map(showOther)}
{children}
</div>
)
Note that we've used children
within the div
; this is because we'll be placing our entire page within this component, to allow
cursors to be seen across the whole page. We're also using connectedId
, which is a handy unique id we use as a React key
.
Export from config
Remember that we're exporting these hooks from the config file:
export const {
useUpdateMyPresence,
useOthers,
// ...
} = createRoomContext<Presence>(client)
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'
Summing up
Here's the entire component again, with added comments:
CursorPresence.ts
import { useUpdateMyPresence, useOthers } from '../liveblocks.config'
import Cursor from './Cursor'
export default function CursorPresence ({ children }) {
// Update your cursor locations with this
const updateMyPresence = useUpdateMyPresence()
// When your cursor moves, update your presence with its current location
const onPointerMove = event => {
updateMyPresence({
cursor: {
x: Math.round(event.clientX),
y: Math.round(event.clientY)
}
})
}
// If your cursor leaves the element, or window, set presence to null
const onPointerLeave = event => {
updateMyPresence({ cursor: null })
}
// Get other users' presence
const others = useOthers()
// Display another's presence
const showOther = ({ connectionId, presence }) => {
// If presence is not set or cursor location is null, don't display
if (!presence?.cursor) {
return null
}
// Display cursor
const { x, y } = presence.cursor
return (
<Cursor key={connectionId} x={x} y={y} />
)
}
return (
<div onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
{others.map(showOther)}
{children}
</div>
)
}
On to the final step now, adding the room!
Creating the room
Each page our app will mostly likely want to have a separate room—we won't want people seeing the cursors used on other pages, and for this reason, we'll define our room at the page level.
All we need to do is place our newly built CursorPresence
component within RoomProvider
,
and pass an initial presence value.
import { RoomProvider } from '../liveblocks.config'
import CursorPresence from '../components/CursorPresence'
export default function Index () {
// Creating a room
return (
<RoomProvider id="index-room" initialPresence={{ cursor: null }}>
<CursorPresence>
Page content here
</CursorPresence>
</RoomProvider>
)
}
Following on from earlier, the initial presence is { cursor: null }
, to represent a cursor that isn't being rendered.
And that's it—we now have working live cursors! You can pass any body content inside <CursorPresence>
, and our page will be rendered. Here's a complete example on CodeSandbox:
An extra touch
With a couple of little changes we can add some fancy cursors, and make sure that everyone sees the correct image.
Edit API
Any data passed to authorize({ userInfo })
, within /pages/api/auth.ts
, will persist while the user is connected to the platform.
We can generate a number that represents an avatar, use this object to store it, and then pass this to each other user. This means that
if a user is given avatar-3.svg
, all other users will see avatar-3.svg
for that user, not just a random avatar. Here's the code before & after:
const result = await authorize({ room, secret })
const result = await authorize({
room,
secret,
userInfo: {
// There are 59 avatars in the set, pick a number at random
avatarId: Math.floor(Math.random() * 58) + 1
}
})
userInfo
is also a helpful place to store usernames, and similar properties retrieved from an authentication system.
This data cannot be changed once set.
Edit Cursor
Now we can add avatarId
to the Cursor
props, which can be used to grab a file, and style the new cursor:
export default function Cursor ({ x = 0, y = 0 }) {
}
return (
<div style={...}>
<CursorImage />
</div>
)
export default function Cursor ({ x = 0, y = 0, avatarId }) {
}
return (
<div style={/* ... */}>
{/* Style new cursor */}
<img src={`/path/to/images/avatar-${avatarId}.svg`} style={...} alt="..." />
</div>
)
Edit CursorPresence
And finally, within CursorPresence
pass the id to the cursor using the info
property (info
will return any data stored in userInfo
):
// Function to display a user's presence
const showOther = ({ connectionId, presence }) => {
...
return (
<Cursor key={connectionId} x={x} y={y} />
)
}
// Function to display a user's presence
const showOther = ({ connectionId, presence, info }) => {
...
return (
<Cursor key={connectionId} x={x} y={y} avatarId={info.avatarId} />
)
}
Fancy cursors complete!
Summary
It's now amazingly easy to create collaborative experiences thanks to Liveblocks. And bear in mind that any data can be sent as presence, not just cursor positions. We've only reached the tip of the iceberg in 2021—collaboration is our future! Make sure to check out my article on animating multiplayer cursors, if you'd like to take your cursor game to the next level: