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.
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 is one plugin: its Overlay paints the highlights, its Mount dev-warns once when
rows or columns move under an active view-index entry. The 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
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 plugins prop lives on the provider, so the
plugin has to exist before it. Every further call mints ANOTHER store: only the plugin wired
into plugins 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 { useEffect } from "react";
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} plugins={[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, and the grid compares the plugins list
entry-wise, so the inline [plugin] array is stable enough as-is.
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. For a transport that sends diffs instead, the per-entry
lifecycle hooks cover the common cases:
const { plugin, setPresenceHighlights, removePresenceHighlight, clearPresenceHighlights } = useDataGridPresence();
socket.on("leave", (leftId) => removePresenceHighlight(leftId)); // entry ids ARE the peer ids
socket.on("room-closed", clearPresenceHighlights); // the room emptiesremovePresenceHighlight(id) removes the one entry whose id matches and leaves every other
entry untouched; it is a no-op when no entry matches. A peer with a multi-range selection holds
one entry per range. For that peer, either key every range entry by the same peer id and remove
each, or resend the filtered snapshot as before. clearPresenceHighlights() removes every entry.
Both re-render DataGridOverlays and nothing else, the same as a snapshot call.
The same composition works with the core's DataGrid convenience wrapper (see the note on
Fill handle).
Zero re-render guarantee
Calling setPresenceHighlights re-renders DataGridOverlays and nothing else: no row, no cell,
and no aria-selected change. If you need a read-back subscription elsewhere, pass storeApi
into useDataGridPresenceHighlights(storeApi). It re-renders only the component that calls it.
Coordinates: rowId-native, or view-index
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:
// 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 },
]);To branch on the form of a received entry, isRowIdPresenceHighlight(entry) and
isRowIdRangePresenceHighlight(entry) narrow a PresenceHighlightEntry. Both come from the
data-grid-presence entry point.
One entry paints at most 1024 fragments (MAX_RESOLVED_RECTS, the default of the maxRects
option). Beyond the budget, the excess fragments are dropped and onExcessRects(entry, kept, total) fires once per entry, with a dev warning in dev:
const { plugin, ...rest } = useDataGridPresence({
maxRects: 256,
onExcessRects: (entry, kept, total) => console.info(`presence "${entry.id}": ${kept}/${total} fragments painted`),
});PresenceHighlight's range form (view-index, the same coordinate system as
GridSelection.current.range) works side by side with the rowId entries in the same array. It has
one caveat: range is in the SENDER's view-index. A receiver with a different local sort or filter
paints it on the wrong cells. A receiver-side live feed with
reorder: "immediate" (see Streaming updates) moves rows in the same
call. The range entry then points at the wrong rows on the receiver itself. Use the rowId-native
forms whenever sender and receiver views can diverge, or when the receiver's own feed reorders
rows while entries are active. The stable onSelectionChange payload (SelectionState) is
rowId-native, so the rowId entries build straight from it. In development, the grid warns once
when a row move, sort, filter,
join operator change, or data replacement moves rows while a view-index entry is active.
The two entry shapes:
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,
useDataGridRowIdToViewRow, which returns a ReadonlyMap<string, number> of the current view.
Rendering
- Highlights reuse the core's overlay pipeline through the plugin
ctx: they clamp to the window, split at pinned columns (pinned segments paint at the pinned cell's fixed screen position), and thecolorbecomes the overlay node's--presence-color: border at full opacity, fill translucent enough that two overlapping highlights both stay legible. - Presence paints below the local active-cell ring and is fully
aria-hiddenandpointer-events-none: it never touchesaria-selectedand never reaches the row-level path. The label chip renders only while the range's top-left corner survives the window clamp.
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.
Broadcasting your selection
Sending your own selection to peers (a real websocket sketch, the onSelectionChange wiring,
and the view-index-vs-rowId payload trade-off) is on the
Broadcasting your selection page.