Custom cursors
Everyone elseโs cursor shows up as an emoji in a ring of their color. Each person gets the same emoji on every screen. Open this page in a second window to see it.
- Keep this page open in your normal window.
- Copy the private-window link, then paste it into a private or incognito window.
- Interact in either window and watch the other one update.
Copy the code
Both versions use the same shared data and behavior. The live playground runs the Vanilla HTML version.
Save this as index.html, or open it in the playground to
test and change it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Custom cursors</title>
<style>
:root {
color: #1c1c1c;
background: #f4efe5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { display: grid; min-height: 100vh; margin: 0; padding: 1.5rem; place-items: center; }
.critter-pad {
display: grid;
gap: 0.5rem;
place-items: center;
width: 100%;
min-height: 14rem;
border: 1.5px dashed #65795d;
border-radius: 8px;
text-align: center;
}
.critter-pad p { margin: 0; max-width: 22rem; line-height: 1.5; }
/* A custom cursor: an emoji in a ring of the person's color. */
.critter {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 3px solid var(--color);
border-radius: 50%;
background: white;
font-size: 18px;
transform: translate(-50%, -50%);
}
</style>
</head>
<body>
<main class="critter-pad">
<p>You're <span id="my-critter"></span> to everyone here.</p>
<p>Open this page in a second window and move your mouse in both.</p>
</main>
<script type="module">
import { playhtml } from "https://unpkg.com/playhtml@latest";
const CRITTERS = ["๐", "๐", "๐ฆ", "๐", "๐ธ", "๐ฆ"];
function critterFor(pid) {
let hash = 0;
for (const char of pid) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return CRITTERS[hash % CRITTERS.length];
}
// Your own pointer as a CSS cursor: the same emoji and ring others see.
function critterCursor(emoji, color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<circle cx="16" cy="16" r="13" fill="white" stroke="${color}" stroke-width="3" />
<text x="16" y="21.5" font-size="15" text-anchor="middle">${emoji}</text>
</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 16 16, auto`;
}
// Called once for each other person's cursor. Fill in `element` and
// return it, or return null to keep the default arrow. playhtml moves the
// element and adds the person's name tag.
function renderCursor(pid, element) {
const person = playhtml.people.everyone.find((p) => p.pid === pid);
const critter = document.createElement("div");
critter.className = "critter";
critter.textContent = critterFor(pid);
critter.style.setProperty("--color", person?.color ?? "#1c1c1c");
element.replaceChildren(critter);
return element;
}
await playhtml.init({
developmentMode: true,
cursors: {
enabled: true,
onCustomCursorRender: renderCursor,
},
});
// onCustomCursorRender only draws other people. Draw your own pointer
// with CSS so you see what everyone else sees.
const me = playhtml.people.me;
document.getElementById("my-critter").textContent = critterFor(me.pid);
document.body.style.cursor = critterCursor(critterFor(me.pid), me.color);
</script>
</body>
</html>
Start with a React + TypeScript project, install the packages below,
then replace src/App.tsx with the component.
npm install playhtml @playhtml/react import { useEffect } from "react";
import { playhtml } from "playhtml";
import { PlayProvider, usePeople } from "@playhtml/react";
const CRITTERS = ["๐", "๐", "๐ฆ", "๐", "๐ธ", "๐ฆ"];
function critterFor(pid: string): string {
let hash = 0;
for (const char of pid) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return CRITTERS[hash % CRITTERS.length];
}
// Your own pointer as a CSS cursor: the same emoji and ring others see.
function critterCursor(emoji: string, color: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<circle cx="16" cy="16" r="13" fill="white" stroke="${color}" stroke-width="3" />
<text x="16" y="21.5" font-size="15" text-anchor="middle">${emoji}</text>
</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 16 16, auto`;
}
// Called once for each other person's cursor. Fill in `element` and
// return it, or return null to keep the default arrow. playhtml moves the
// element and adds the person's name tag.
function renderCursor(pid: string, element: HTMLElement): HTMLElement {
const person = playhtml.people.everyone.find((p) => p.pid === pid);
const critter = document.createElement("div");
critter.className = "critter";
critter.textContent = critterFor(pid);
critter.style.setProperty("--color", person?.color ?? "#1c1c1c");
element.replaceChildren(critter);
return element;
}
function CritterPad() {
const { me } = usePeople();
// onCustomCursorRender only draws other people. Draw your own pointer
// with CSS so you see what everyone else sees.
useEffect(() => {
document.body.style.cursor = critterCursor(critterFor(me.pid), me.color);
}, [me.pid, me.color]);
return (
<div className="critter-pad">
<p>You're {critterFor(me.pid)} to everyone here.</p>
<p>Open this page in a second window and move your mouse in both.</p>
</div>
);
}
export default function App() {
return (
<PlayProvider initOptions={{
developmentMode: true,
cursors: { enabled: true, onCustomCursorRender: renderCursor },
}}>
<main>
<CritterPad />
</main>
<style>{`
:root { color: #1c1c1c; background: #f4efe5; font-family: system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
#root { display: grid; min-height: 100vh; padding: 1.5rem; place-items: center; }
.critter-pad {
display: grid;
gap: 0.5rem;
place-items: center;
width: 100%;
min-height: 14rem;
border: 1.5px dashed #65795d;
border-radius: 8px;
text-align: center;
}
.critter-pad p { margin: 0; max-width: 22rem; line-height: 1.5; }
/* A custom cursor: an emoji in a ring of the person's color. */
.critter {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 3px solid var(--color);
border-radius: 50%;
background: white;
font-size: 18px;
transform: translate(-50%, -50%);
}
`}</style>
</PlayProvider>
);
} Draw the cursor
Section titled โDraw the cursorโPass onCustomCursorRender with your cursor options. playhtml calls it once
per person with their pid and an empty element. Fill the element and return
it. playhtml positions it and adds the name tag:
function renderCursor(pid, element) {
const person = playhtml.people.everyone.find((p) => p.pid === pid);
const critter = document.createElement("div");
critter.className = "critter";
critter.textContent = critterFor(pid);
critter.style.setProperty("--color", person?.color ?? "#1c1c1c");
element.replaceChildren(critter);
return element;
}
await playhtml.init({
cursors: { enabled: true, onCustomCursorRender: renderCursor },
});
Return null to keep the default arrow for someone.
Your own pointer
Section titled โYour own pointerโonCustomCursorRender only draws other people. To show your own pointer the
same way, set a CSS cursor image on the page after init:
const me = playhtml.people.me;
document.body.style.cursor = critterCursor(critterFor(me.pid), me.color);
critterCursor draws the emoji and ring into an SVG data URL. A CSS cursor
moves with no lag, but it has to be an image, so it canโt hold a live element.
Same emoji everywhere
Section titled โSame emoji everywhereโcritterFor hashes the pid into an index, so a person keeps one emoji across
every screen and every visit without saving anything.
See Cursors for the other cursor options.