@ws-asyncapi/cursors
v0.1.0
Published
Opt-in live-cursors helper for ws-asyncapi — a smoothed, framework-agnostic store over presence (works with the react/solid bindings via useStore/fromStore)
Readme
@ws-asyncapi/cursors
Opt-in live cursors for ws-asyncapi — show
everyone's pointer in real time. It adds no protocol and no opinion to the core
libs: it's a thin, smoothed projection over the general presence machinery.
You send with the general volatile primitive (client.presence.update); this
package turns the incoming presence roster into a smoothed Map<id, {x,y}> of
everyone else's cursor for you to render.
Install
npm install @ws-asyncapi/cursors # peer: @ws-asyncapi/query-core
# perfect-cursors is OPTIONAL (a drop-in smoother), never requiredYou also need a framework binding (@ws-asyncapi/react or @ws-asyncapi/solid)
and the server (@ws-asyncapi/adapter-node or -elysia).
Quick start
1. Put a cursor field in your channel's presence schema (your names, your shape):
// server.ts
import { Channel } from "ws-asyncapi";
import { z } from "zod";
export const board = new Channel("/board/:id", "board").presence(
z.object({
name: z.string(),
color: z.string(),
cursor: z.object({ x: z.number(), y: z.number() }).nullable(),
}),
);
// serve it with createNodeWsServer([board]) / wsAsyncAPIAdapter([board])2. Announce yourself, stream your cursor, render the others (React):
// Board.tsx
import { useEffect, useMemo } from "react";
import { createReactClient, useStore } from "@ws-asyncapi/react";
import { cursorsStore } from "@ws-asyncapi/cursors";
import type { board } from "./server";
// one client per app; coalesce cursor moves to ~20Hz on the wire
const ws = createReactClient<typeof board>("ws://localhost:3000", "/board/1", {
presenceThrottle: 50,
});
export function Board() {
useEffect(() => {
// join the roster once…
ws.client.presence.set({ name: "Alice", color: "#e11d48", cursor: null });
// …then stream the cursor (volatile, throttled, dropped while offline)
const onMove = (e: PointerEvent) =>
ws.client.presence.update({ cursor: { x: e.clientX, y: e.clientY } });
window.addEventListener("pointermove", onMove);
return () => window.removeEventListener("pointermove", onMove);
}, []);
// others' smoothed cursors (yourself excluded)
const cursors = useStore(useMemo(() => cursorsStore(ws.client), []));
return (
<>
{[...cursors].map(([id, { x, y }]) => (
<svg
key={id}
width="24"
height="24"
style={{
position: "fixed",
left: 0,
top: 0,
transform: `translate(${x}px, ${y}px)`,
pointerEvents: "none",
}}
>
<path d="M0 0 L0 18 L5 13 L9 21 L12 19 L8 12 L15 12 Z" fill="black" />
</svg>
))}
</>
);
}That's the whole feature. cursors re-renders ~60fps with interpolated
positions even though the wire rate is ~20Hz.
Solid
Same store, bound with fromStore:
import { fromStore } from "@ws-asyncapi/solid";
import { cursorsStore } from "@ws-asyncapi/cursors";
const cursors = fromStore(cursorsStore(ws.client)); // Accessor<Map<id, {x,y}>>
// render with <For each={[...cursors()]}>{([id, p]) => ...}</For>Labels, colors, avatars
cursorsStore returns positions only (Map<id, {x,y}>). The rest of each
member's state lives in presence — read it with usePresence() and join by
socket id:
const cursors = useStore(useMemo(() => cursorsStore(ws.client), []));
const { members } = ws.usePresence(); // Map<id, { name, color, cursor }>
{[...cursors].map(([id, { x, y }]) => {
const m = members.get(id);
return <Cursor key={id} x={x} y={y} name={m?.name} color={m?.color} />;
})}Smoothing
Default is a zero-dependency rAF lerp (smooth 60fps render from a ~20Hz wire
rate; degrades to passthrough when there's no requestAnimationFrame, e.g. SSR).
Swap it via smoothing:
cursorsStore(ws.client, { smoothing: false }); // raw last-write
import { PerfectCursor } from "perfect-cursors"; // your install
cursorsStore(ws.client, { smoothing: (cb) => new PerfectCursor(cb) }); // best-in-class splineperfect-cursors already implements the smoother interface
({ addPoint([x,y]); dispose() }), so it drops in without being a dependency
here.
Choosing the cursor field
The default reads state.cursor. Point it elsewhere (type-checked against your
presence schema):
cursorsStore(ws.client, { field: (s) => s.pointer });Return null/undefined to omit a member (e.g. their pointer left the canvas) —
set cursor: null via presence.update({ cursor: null }) and they drop out of
the map.
Coordinates
{x,y} are whatever you put in — this package never transforms them. For a
plain web page, screen coords (clientX/clientY) are fine. For a pan/zoom canvas
send document/scene coordinates and apply each viewer's transform on render;
for very different screen sizes, send normalized coords (e.g. vw/vh
fractions). That choice is yours and stays in your presence schema.
How it updates
client.presence.update({ cursor }) → volatile diff → server fan-out → the
receiver's cursorsStore feeds each peer's point into its smoother → the
smoother's rAF loop emits interpolated points → a fresh Map snapshot →
useStore/fromStore re-renders. Yourself is excluded; peers that leave or null
their cursor are removed and their animation loop disposed. Cursors are
ephemeral — never persisted, never replayed on reconnect.
Why a separate package
The general libs (@ws-asyncapi/client, query-core, react, solid) stay
free of any cursor-shaped assumption. Cursors ride entirely on two existing
seams — the client's presence surface and query-core's Subscribable
contract — bound to your framework with its generic store hook (useStore /
fromStore). Nothing here is in the hot path of apps that don't use cursors.
License
MIT
