Shared doodle strip
Draw on the pad, then add your doodle to a row everyone shares. Strokes stay in your browser. Only the finished 64×64 image goes into shared data.
- 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>Shared doodle strip</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; }
main { display: grid; gap: 0.75rem; justify-items: start; }
.pad {
width: 192px;
height: 192px;
border: 1.5px solid #1c1c1c;
background: white;
image-rendering: pixelated;
touch-action: none;
cursor: crosshair;
}
.strip {
display: flex;
gap: 6px;
max-width: 100%;
min-height: 64px;
overflow-x: auto;
}
.strip img {
width: 64px;
height: 64px;
border: 1px solid #1c1c1c;
background: white;
image-rendering: pixelated;
}
</style>
</head>
<body>
<main>
<canvas id="doodle-pad" class="pad" width="64" height="64"></canvas>
<div>
<button id="doodle-clear">Clear</button>
<button id="doodle-add">Add to row</button>
</div>
<div id="doodle-strip" class="strip"></div>
</main>
<script type="module">
import { playhtml } from "https://unpkg.com/playhtml@latest";
const SIZE = 64;
const MAX_DOODLES = 20;
const pad = document.getElementById("doodle-pad");
const pen = pad.getContext("2d");
pen.lineWidth = 4;
pen.lineCap = "round";
pen.lineJoin = "round";
// Pointer position in canvas pixels (the canvas is drawn 3x larger).
function toPad(event) {
const rect = pad.getBoundingClientRect();
return [
((event.clientX - rect.left) / rect.width) * SIZE,
((event.clientY - rect.top) / rect.height) * SIZE,
];
}
let drawing = false;
pad.addEventListener("pointerdown", (event) => {
drawing = true;
pad.setPointerCapture(event.pointerId);
pen.beginPath();
pen.moveTo(...toPad(event));
pen.lineTo(...toPad(event)); // a click leaves a dot
pen.stroke();
});
pad.addEventListener("pointermove", (event) => {
if (!drawing) return;
pen.lineTo(...toPad(event));
pen.stroke();
});
pad.addEventListener("pointerup", () => (drawing = false));
const clearPad = () => pen.clearRect(0, 0, SIZE, SIZE);
document.getElementById("doodle-clear").addEventListener("click", clearPad);
// The shared row. Only finished doodles are shared, never the strokes.
const strip = playhtml.register("doodle-strip", {
defaultData: { doodles: [] },
update: ({ element, data }) => {
element.replaceChildren(
...data.doodles.map((doodle) => {
const img = document.createElement("img");
img.src = doodle.src;
img.alt = "";
return img;
}),
);
},
});
document.getElementById("doodle-add").addEventListener("click", () => {
const src = pad.toDataURL("image/png");
strip.setData((data) => {
data.doodles.push({ id: crypto.randomUUID(), src });
// Keep the newest 20. Shared arrays support splice, not shift.
if (data.doodles.length > MAX_DOODLES) {
data.doodles.splice(0, data.doodles.length - MAX_DOODLES);
}
});
clearPad();
});
await playhtml.init({ developmentMode: true });
</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 { useRef, type PointerEvent } from "react";
import { PlayProvider, withSharedState } from "@playhtml/react";
const SIZE = 64;
const MAX_DOODLES = 20;
type Doodle = { id: string; src: string };
const DoodleStrip = withSharedState(
{ id: "doodle-strip", defaultData: { doodles: [] as Doodle[] } },
({ data, setData }) => {
const padRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const pen = () => padRef.current!.getContext("2d")!;
// Pointer position in canvas pixels (the canvas is drawn 3x larger).
function toPad(event: PointerEvent<HTMLCanvasElement>): [number, number] {
const rect = event.currentTarget.getBoundingClientRect();
return [
((event.clientX - rect.left) / rect.width) * SIZE,
((event.clientY - rect.top) / rect.height) * SIZE,
];
}
function startStroke(event: PointerEvent<HTMLCanvasElement>) {
drawing.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
const ctx = pen();
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(...toPad(event));
ctx.lineTo(...toPad(event)); // a click leaves a dot
ctx.stroke();
}
function continueStroke(event: PointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return;
pen().lineTo(...toPad(event));
pen().stroke();
}
const clearPad = () => pen().clearRect(0, 0, SIZE, SIZE);
// Only finished doodles are shared, never the strokes.
function addToRow() {
const src = padRef.current!.toDataURL("image/png");
setData((draft) => {
draft.doodles.push({ id: crypto.randomUUID(), src });
// Keep the newest 20. Shared arrays support splice, not shift.
if (draft.doodles.length > MAX_DOODLES) {
draft.doodles.splice(0, draft.doodles.length - MAX_DOODLES);
}
});
clearPad();
}
return (
<>
<canvas
ref={padRef}
className="pad"
width={SIZE}
height={SIZE}
onPointerDown={startStroke}
onPointerMove={continueStroke}
onPointerUp={() => (drawing.current = false)}
/>
<div>
<button onClick={clearPad}>Clear</button>
<button onClick={addToRow}>Add to row</button>
</div>
<div className="strip">
{data.doodles.map((doodle) => (
<img key={doodle.id} src={doodle.src} alt="" />
))}
</div>
</>
);
},
);
export default function App() {
return (
<PlayProvider initOptions={{ developmentMode: true }}>
<main>
<DoodleStrip />
</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; }
main { display: grid; gap: 0.75rem; justify-items: start; }
.pad {
width: 192px;
height: 192px;
border: 1.5px solid #1c1c1c;
background: white;
image-rendering: pixelated;
touch-action: none;
cursor: crosshair;
}
.strip {
display: flex;
gap: 6px;
max-width: 100%;
min-height: 64px;
overflow-x: auto;
}
.strip img {
width: 64px;
height: 64px;
border: 1px solid #1c1c1c;
background: white;
image-rendering: pixelated;
}
`}</style>
</PlayProvider>
);
} Share results, not every stroke
Section titled “Share results, not every stroke”The canvas is ordinary local state. Writing every pointer move to shared data would sync hundreds of tiny updates per doodle. Instead, “Add to row” turns the canvas into a small PNG data URL and pushes one entry:
strip.setData((data) => {
data.doodles.push({ id: crypto.randomUUID(), src });
if (data.doodles.length > MAX_DOODLES) {
data.doodles.splice(0, data.doodles.length - MAX_DOODLES);
}
});
Keep the list bounded
Section titled “Keep the list bounded”Shared data persists, so a list that only grows keeps getting slower to load.
The strip keeps the newest 20 doodles. Shared arrays support splice, not
shift, so trim from the front with splice.
See data best practices for mutator writes and list limits.