Overlay plugins
The overlay-plugin seam. Paint your own state into the grid's overlay layer, with zero core knowledge of what you're painting.
The plugin contract
An overlay plugin is a function that renders one more pointer-events-none layer into the
grid's overlay stack, with no store changes and no core code aware of what it paints.
export type OverlayPlugin = (ctx: OverlayPluginCtx) => ReactNode;A plugin is a plain function, not a component and not a hook by name, though it may call hooks
internally (see Identity stability below for why that is safe). DataGridOverlays
calls every registered plugin once per render, in array order, and renders whatever each one
returns.
The overlay layer is a stack of pointer-events-none elements painted above the cell grid, inside
the same scrolling canvas as the cells. Selection range fills, the row/column selection bands, and
the active-cell focus ring are the core's own built-in overlay layers. data-grid-presence and
data-grid-fill both register into this same seam. Presence paints remote users' selections. Fill
paints its drag preview and the small drag handle. Neither the core nor the other add-on knows the
other exists. Both reuse the same window-clamp and pin-zone-splitting math the core's own overlays
use, supplied through a ctx argument, so a plugin never re-implements geometry the core already
solved.
Layer order
DataGridOverlays renders, in this exact order, top-most last:
- Selection-range fills (
current.range, thenrangeStack). - Row-channel and column-channel selection bands.
- Every registered
overlayPluginsentry, in array order. - The local active-cell ring.
Local focus always wins
Registered plugins render before the active-cell ring, never after. A plugin painting remote or transient state, a presence highlight, a fill-drag preview, always loses visually to your own active cell on the same square.
When it re-renders
DataGridOverlays subscribes to selection and the active cell through atomic Zustand selectors. A
plugin that calls its own hooks inside the returned function (see useDataGridPresence's pattern
below) attributes those subscriptions to DataGridOverlays's own fiber, because a plain function
call still runs its hooks against the calling component. So a plugin's state change re-renders
DataGridOverlays, and only DataGridOverlays. It never reaches rows or cells, which subscribe
through a separate, row-scoped path. This is what makes a presence highlight or a fill-drag frame
cost zero row or cell renders, verified by a render-count probe in each add-on's own test suite.
What ctx provides
Prop
Type
Each field exists because a built-in overlay layer already needed it, and a plugin needs the exact same pipeline to paint correctly instead of drifting from it:
windowStart— the first rendered (data-space) row index. Overlays place content window-relative, the same way the rows canvas does, so a plugin'sgridRowStartmust subtract this, not the true row index, or its content paints in the wrong place the moment the grid scrolls.clampRowStart/clampRowEnd— the true contiguous[start, end)rendered row span. This differs fromwindowStartplus the row count whenever an off-window active row is appended past the contiguous window (moving the active cell can pull in one disjoint row). Clamp any rect you paint to this range, not towindowStart, or it can overshoot or undershoot the real rendered bottom.colCount— the total number of visible columns, in real column-index space. This is not the rendered column window's size. A pinned-right zone can sit far outside the currently scrolled column window, so pin-zone splitting needs the true column count to reach it.colOffset— the 1-based grid-column offset to add to every rect'sx. It is2when a row marker column occupies track 1, else1. Add it once, at the point you setgridColumnStart/gridColumnEnd.pinTrack(optional) — per-column pin and track data, present only when the grid actually has a pinned column.undefinedmeans "nothing pinned, skip segmentation entirely."splitRectByPinZones(rect, pinTrack)— splits a data-space rect into up to three contiguous segments: pinned-left, unpinned, pinned-right. Pinned columns sit at a fixed screen position regardless of scroll, counter-offset from the scrolling canvas. A rect that spans a pinned column, without this split, paints at that column's scrolled-away track position instead of its actual, fixed, on-screen position. Call it wheneverpinTrackis defined and your rect might span a pinned column.clampRectToWindow(rect, rowStart, rowEnd, colCount)— clamps a data-space rect to the given row window and[0, colCount), or returnsnullwhen the rect falls fully outside it. Call this beforesplitRectByPinZones, exactly as the built-in layers do, so a plugin never paints a rect that only partially exists in the current window.
Identity stability
overlayPlugins on DataGridProvider is readonly OverlayPlugin[]. A dev-mode guardrail warns
when this array's identity changes across renders:
overlayPlugins array identity changed since the last render; pass a stable reference (module scope
or useMemo) or DataGridOverlays re-renders every tickThe array must be stable, not only the functions inside it. useDataGridPresence() and
useDataGridFill() both satisfy this the same way: the plugin itself comes from a one-time
useState initializer or a useMemo keyed on something that never changes for the hook's
lifetime, so its identity never changes across renders:
export function useDataGridPresence(): UseDataGridPresenceResult {
const [store] = useState(() => createPresenceStore());
const plugin = useMemo<OverlayPlugin>(() => makePresencePlugin(store), [store]);
return { plugin, setPresenceHighlights: store.getState().setPresenceHighlights, storeApi: store };
}Pass the array itself through a stable reference too. overlayPlugins={[plugin]} written inline in
JSX allocates a fresh array on every render of your own component. Wrap it in useMemo, or build
the array once outside the render path, so the array reference, not only its single element, stays
stable:
const { plugin } = useDataGridPresence();
const overlayPlugins = useMemo(() => [plugin], [plugin]);
<DataGridProvider {...grid} columns={columns} overlayPlugins={overlayPlugins}>Combining more than one plugin follows the same rule: memoize the combined array on the identities of its members, not the array literal.
Coordinate space: view-space, not rowId
Every rect a plugin paints (highlight.range, a fill-drag preview, and so on) is in view-space:
row indices into the current sorted or filtered display order, the same coordinate system as
GridSelection.current.range. It is not an index into your raw data array, and it is not a
rowId.
This matters the moment two independent grids, or a grid and a remote peer, disagree about sort or
filter state. A view-row index computed on one side goes stale the instant the other side's view
changes. The fix, worked through in full with a rowId-to-view-row lookup helper, lives on
Multiplayer presence.
Any plugin that receives coordinates from outside the grid, a network peer, a URL parameter,
anywhere but the grid's own current selection, needs the same rowId-to-view-row mapping step before
it paints.
Worked example: highlight a column
A plugin does not need selection or presence state to be useful. This one highlights a single column with a tinted band, driven entirely by a prop your own component controls, for example the column currently under a filter, or one flagged by a validation pass elsewhere in your app.
"use client";
import { useMemo } from "react";
import type { OverlayPlugin, OverlayPluginCtx, GridRect } from "@/components/data-grid/data-grid";
function ColumnHighlightOverlay({ col, ctx }: { col: number; ctx: OverlayPluginCtx }) {
const { windowStart, clampRowStart, clampRowEnd, colCount, colOffset, pinTrack, splitRectByPinZones, clampRectToWindow } = ctx;
if (col < 0 || col >= colCount) return null;
const rect: GridRect = { x: col, y: clampRowStart, width: 1, height: clampRowEnd - clampRowStart };
const clamped = clampRectToWindow(rect, clampRowStart, clampRowEnd, colCount);
if (!clamped) return null;
const pinned = pinTrack && pinTrack.pins.some((p) => p === "left" || p === "right") ? pinTrack : undefined;
const segments = pinned ? splitRectByPinZones(clamped, pinned) : [{ rect: clamped, pinStyle: undefined }];
return (
<>
{segments.map((seg, i) => (
<div
key={i}
aria-hidden="true"
className="pointer-events-none bg-amber-300/20"
style={{
gridColumnStart: seg.rect.x + colOffset,
gridColumnEnd: seg.rect.x + seg.rect.width + colOffset,
gridRowStart: seg.rect.y - windowStart + 1,
gridRowEnd: seg.rect.y + seg.rect.height - windowStart + 1,
...seg.pinStyle,
}}
/>
))}
</>
);
}
// re-run useMemo only when highlightColumn itself changes, so the plugin's identity stays stable
// across unrelated re-renders of the calling component (see Identity stability above).
export function useColumnHighlight(highlightColumn: number | null): OverlayPlugin {
return useMemo<OverlayPlugin>(
() => (ctx) => (highlightColumn === null ? null : <ColumnHighlightOverlay col={highlightColumn} ctx={ctx} />),
[highlightColumn],
);
}Wire it in the same shape as useDataGridPresence:
function MyGrid() {
const [highlightColumn, setHighlightColumn] = useState<number | null>(null);
const plugin = useColumnHighlight(highlightColumn);
const overlayPlugins = useMemo(() => [plugin], [plugin]);
return (
<DataGrid data={data} columns={columns} getRowId={getRowId} overlayPlugins={overlayPlugins} />
);
}Every rect this plugin paints comes from ctx, clampRowStart/clampRowEnd/colCount, so it
never has to know the current scroll position or the rendered row window itself. The plugin stays
correct through scrolling, resizing, and pinning changes for free.
What plugins cannot do
The overlay-plugin seam is paint-only. A plugin has no pointer-event handlers of its own: every
element it renders is pointer-events-none, and the seam has no hook for pointer-down, drag, or
click on plugin content.
data-grid-fill needs real drag interaction for its handle, and solves this with a second,
separate piece: FillHandleTracker, a component you render as a child inside DataGridRoot
itself, one level below where overlayPlugins is wired (on DataGridProvider, above DataGridRoot).
It reads scrollRef and the live column layout from DataGridRoot's own context, runs the pointer
math, and registers its handlers into a small store that the paint-only plugin reads back out of to
wire onto the handle square it renders. See Fill handle and
fill-tracker.tsx for the full pattern if your own plugin needs pointer interaction, not only
paint.
SSR
DataGridOverlays is a client component ("use client"), rendered inside DataGridProvider's own
subtree. It server-renders like any other client component, no different from the core's own
overlay layers, as long as a plugin's own rendering stays deterministic between server and client
(see the SSR note on custom cell types for the
general pattern: pin any locale-dependent formatting explicitly, and avoid reading browser-only
globals during render).