gridcn

Multiplayer presence

Paint remote users' selections into the grid with zero core re-renders.

Multiplayer presence lives in the separate data-grid-presence add-on.

Click a header to sort: Ada's and Grace's highlights are fixed to view positions and jump around, but Linus's 2×2 range is tied to its row ids and keeps following the same rows — a local filter that splits the range paints one labeled rect per fragment.

Name
Email
Age
Role
Score
Silent Tiger
user0@example.com
63
Viewer
4
Quick Lion
user1@example.com
67
Viewer
89
Swift Wolf
user2@example.com
40
Manager
80
Eager Lion
user3@example.com
50
User
51
Eager Tiger
user4@example.com
45
Viewer
83
Silent Wolf
user5@example.com
24
User
15
Swift Lion
user6@example.com
34
Viewer
62
Eager Lion
user7@example.com
21
Manager
42
Eager Eagle
user8@example.com
25
Editor
35
Bold Tiger
user9@example.com
19
Editor
52
Bold Bear
user10@example.com
42
User
28
Swift Wolf
user11@example.com
47
Manager
9
Swift Bear
user12@example.com
41
Viewer
45
Eager Wolf
user13@example.com
35
Editor
44
Swift Eagle
user14@example.com
65
Viewer
51
Quick Lion
user15@example.com
22
Manager
28
Bold Wolf
user16@example.com
30
Editor
76
Swift Eagle
user17@example.com
19
Admin
26
Eager Eagle
user18@example.com
35
Manager
70
Eager Bear
user19@example.com
33
Editor
67
Silent Lion
user20@example.com
48
Manager
89
Silent Tiger
user21@example.com
62
Manager
40
Eager Tiger
user22@example.com
51
Manager
82
Silent Lion
user23@example.com
26
Admin
16
pnpm dlx shadcn@latest add @gridcn/data-grid-presence

API

import { useDataGridPresence } from "@/components/data-grid-presence/data-grid-presence";

type PresenceHighlight = {
  /** Must be unique per entry — a multi-range selection sends one entry per range, each with its own id. */
  id: string;
  /** Any CSS color; painted at fixed alpha for the fill, full opacity for the border/chip. */
  color: string;
  /** View-space rect (same coordinate system as GridSelection.current.range). */
  range: GridRect;
  /** Optional name chip anchored at the range's top-left corner, only while it's on-window. */
  label?: string;
};

type RowIdPresenceHighlight = {
  id: string;
  color: string;
  /** Resolved to a view-row by the plugin itself; dropped silently if filtered out of view. */
  rowId: string;
  columnId: string;
  label?: string;
};

type RowIdRangePresenceHighlight = {
  id: string;
  color: string;
  /** Resolved per row by the plugin itself; ids filtered out of view are dropped silently. */
  rowIds: string[];
  /** Resolved per column by the plugin itself; hidden/unknown ids are dropped (dev-warned once). */
  columnIds: string[];
  label?: string;
};

setPresenceHighlights takes an array of any of the three forms, mixed freely. The id must be unique per entry — a user with a multi-range selection sends one entry per range, each with its own id (each entry paints one rect, or one rect per contiguous fragment for the range form). See Coordinates below.

Wiring it up

The add-on registers into the generic overlay-plugin seam of the core (overlayPlugins on DataGridProvider). A plugin renders into the same overlay layer that selection and the fill-preview use, and receives a ctx with the same window-clamp and pin-zone segmentation helpers that the built-in overlays of the core use. See Overlay plugins for the full seam, including every ctx field and a worked example of a plugin from scratch.

useDataGridPresence() is a single hook. Call it EXACTLY ONCE per grid, in your own component ABOVE where you render <DataGridProvider>. The overlayPlugins prop lives on the provider, so the plugin has to exist before it. Every further call mints ANOTHER store: only the plugin wired into overlayPlugins paints, so a second instance's setPresenceHighlights writes into a store nothing reads. If other components need the setter or the read-back storeApi, pass them down as props from the single call:

"use client";

import { DataGrid } from "@/components/data-grid/data-grid";
import { useDataGridPresence } from "@/components/data-grid-presence/data-grid-presence";

function MyGrid() {
  const { plugin, setPresenceHighlights } = useDataGridPresence();

  useEffect(() => {
    const socket = connectToRoom(roomId);
    socket.on("presence", (remoteHighlights) => setPresenceHighlights(remoteHighlights));
    return () => socket.close();
  }, [setPresenceHighlights, roomId]);

  return <DataGrid data={data} columns={columns} getRowId={getRowId} overlayPlugins={[plugin]} />;
}

setPresenceHighlights(highlights) is the real, imperative mechanism. Call it directly from your transport handler: a websocket message, a CRDT awareness update, or a polling tick. The identity of plugin never changes across renders, so passing overlayPlugins={[plugin]} never trips the identity-stability dev guardrail of the core.

setPresenceHighlights REPLACES the whole list (snapshot semantics): each call must carry the full current set of remote entries, not a diff — the natural shape of a websocket "presence state" message or a CRDT awareness snapshot. When a peer leaves, drop their entries and set the remainder (clear-on-leave), or setPresenceHighlights([]) when the room empties:

const { plugin, setPresenceHighlights, storeApi } = useDataGridPresence();

socket.on("leave", (leftId) =>
  setPresenceHighlights(storeApi.getState().highlights.filter((h) => h.id !== leftId)),
);

Because the id must be unique per entry, this per-user filter is well-defined.

Composing with core's DataGrid convenience wrapper

<DataGrid overlayPlugins={[plugin]}> works the same way as <DataGridProvider>. Both accept the prop and forward it straight through to the same seam.

Zero re-render guarantee

DataGridOverlays invokes the plugin function during its own render. Its internal highlights subscription, a small Zustand store local to useDataGridPresence and entirely separate from the store of the core, attributes to THAT call, not to your own component. Calling setPresenceHighlights re-renders DataGridOverlays and nothing else: no row, no cell, and no aria-selected change. See the render-count probe in data-grid-presence.test.tsx (multiplayer presence: setPresenceHighlights renders ONLY the highlights subscriber, never rows/cells).

If you need a read-back subscription elsewhere, for example an inspector panel, pass storeApi (also returned by useDataGridPresence()) into useDataGridPresenceHighlights(storeApi). That hook re-renders ONLY the component that calls it, the same as the plugin's own subscription.

Coordinates: rowId-native, or view-space

A remote peer's cell is identified by { rowId, columnId }, not a view position:

// remote peer sends { userId, color, rowId, columnId }
setPresenceHighlights([
  { id: remote.userId, color: remote.color, rowId: remote.rowId, columnId: remote.columnId },
]);

The plugin resolves rowId to this grid's own current view row internally, so a locally-active sort or filter never mispaints it. A rowId that fell out of the current filter is dropped silently — nothing paints for that highlight until the row is back in view.

The rowId-native RANGE form (RowIdRangePresenceHighlight) is the divergence-proof multi-cell selection: the plugin resolves every rowId and columnId against this grid's own view, drops rows that fell out of the current filter silently (and dev-warns once per entry when a columnId is hidden or unknown), and paints the surviving cells as one rect per contiguous run of resolved rows × runs of resolved columns — a local filter that splits the range paints one rect per fragment instead of one wrong rect. One entry paints at most 1024 fragments; beyond that the excess is dropped with a dev warning:

// remote peer sends { userId, color, rowIds, columnIds } — no view-space coordinates involved
setPresenceHighlights([
  { id: remote.userId, color: remote.color, rowIds: remote.rowIds, columnIds: remote.columnIds },
]);

PresenceHighlight's original range form (view-space, the same coordinate system as GridSelection.current.range) still works side by side with the rowId entries in the same array — it is the send side that needs no hooks at all (the payload already carries the selection's range), at the cost of the view-space trade-off below:

type PresenceHighlight = {
  id: string;
  color: string;
  range: GridRect; // view-space: indices into the sorted/filtered display order
  label?: string;
};

type RowIdPresenceHighlight = {
  id: string;
  color: string;
  rowId: string;
  columnId: string;
  label?: string;
};

Building the map yourself (e.g. to resolve a rowId outside the plugin) uses the same core hook:

import { useDataGridRowIdToViewRow } from "@/components/data-grid/data-grid";

const rowIdToViewRow = useDataGridRowIdToViewRow(); // ReadonlyMap<string, number>
const viewRow = rowIdToViewRow.get(remote.rowId); // undefined when filtered out of view

useDataGridRowIdToViewRow subscribes to viewIndex/data/getRowId identity only and rebuilds the map in a useMemo keyed on those — the O(n) cost lands once per actual view change (sort, filter, insert, delete), never per keystroke, selection step, or streaming tick.

Rendering

  • It reuses the exact overlay pipeline that the core's own RangeOverlay has, through the plugin ctx: clampRectToWindow, then splitRectByPinZones, then grid-line placement. A highlight that spans a pinned column splits into pinned and unpinned segments just like a selection range. Pinned segments paint at the actual screen position of the pinned cell, not its scrolled-away track position.
  • The color of each highlight becomes the CSS custom property --presence-color on its own overlay node. The border paints at full opacity, and the fill paints at color-mix(in oklch, var(--presence-color) 12%, transparent), translucent enough that two overlapping highlights both stay legible.
  • The label chip renders only while the top-left corner of the range survives the window clamp (the same guard that the fill handle's own corner check uses). There is no floating or repositioning at the viewport edge.
  • Presence paints below the local active-cell ring. Your own focus always wins visually over a remote highlight on the same cell, because the overlay-plugin seam renders plugins before the ring for exactly this reason.
  • Everything is aria-hidden and pointer-events-none. Presence never touches aria-selected, never subscribes through useDataGridRowCellState, and never reaches the row-level path at all.

Scope cuts

  • Data rows only. There is no presence painting on pinned row bands, since they already sit outside selection.
  • No cursors, no per-cell avatars, and no "who is online" chrome: presence is the in-window highlight overlay only — any roster/avatar UI is plain app-level code the consumer builds themselves, not a grid feature.
  • The rowId-native forms (RowIdPresenceHighlight, RowIdRangePresenceHighlight) resolve to stable ids, not view positions: the range form paints one rect per contiguous run of the resolved cells, so a local filter that splits a range renders multiple rects, never a wrong one. A range entry is in the sender's view space, so a receiver with a different local sort/filter paints it on the wrong cells.

Demo wiring sketch (real websocket)

The demo above simulates 3 users with setInterval. A real integration looks like this:

"use client";

import { useDataGridPresence } from "@/components/data-grid-presence/data-grid-presence";

function usePresenceSync(roomId: string) {
  const { plugin, setPresenceHighlights } = useDataGridPresence();

  useEffect(() => {
    const socket = new WebSocket(`wss://your-server/rooms/${roomId}`);
    socket.onmessage = (event) => {
      const highlights = JSON.parse(event.data);
      setPresenceHighlights(highlights);
    };
    return () => socket.close();
  }, [setPresenceHighlights, roomId]);

  return plugin;
}

Your own selection still needs to be broadcast in the other direction. Wire onSelectionChange on DataGridProvider rather than hand-rolling a useDataGridSelection() plus useEffect subscription. It already fires on every committed change (click, extend step, row, column, or all selection, and clear) from the actions layer of the store. It also fires once per step of a drag-extend gesture, exactly the granularity that a live broadcast wants.

The multi-cell stopgap is the view-space range form: the payload already carries the selection's range (view space), so the send side needs NO hooks at all — no useDataGridRowIds called outside the provider (core hooks throw when called outside it), and no column-indexing by hand (cell.col is a view-space index into the VISIBLE columns; indexing the raw columns array is wrong as soon as a column is hidden):

<DataGridProvider
  data={data}
  columns={columns}
  getRowId={getRowId}
  overlayPlugins={[plugin]}
  onSelectionChange={(selection) => {
    const range = selection.current?.range;
    if (!range) return; // cleared — send the leave message instead
    socket.send(JSON.stringify({ id: userId, color, range }));
  }}
>

Caveat (the multi-cell mapping limitation): range coordinates are in the SENDER's view space, so a receiver with a different local sort or filter paints them on the wrong cells. For divergence-proof multi-cell broadcasts, send the rowId-native range form { rowIds, columnIds } instead: on the send side, map the selection's view rows to rowIds with useDataGridRowId / useDataGridRowIds and the visible view columns to column ids, called INSIDE the provider. The receiver's plugin resolves every id against its own view — filtered rows are dropped silently, hidden/unknown columns dev-warn once — no mapping code on the receive side. See Events & state for the full payload shape.

On this page