# Real-time collaboration

Presence (who is here + where their cursor is) and live edits (a change in one client lands in every other) over a pluggable transport. createCollaboration + broadcastChannelTransport sync cursors and edits across tabs with zero backend; swap the transport for a WebSocket to go cross-machine. Also the substrate for multiple AI agents editing one grid.

Uses `@svgrid/grid` (MIT). Category: Real-time & Streaming. Live demo: https://svgrid.com/demos/149-realtime-collaboration/

Install: `npm i @svgrid/grid` (Svelte 5).

## About this example

Real-time collaboration on the Svelte 5 data grid: presence showing who is here and where their cursor is, and live edits where a change in one client lands in every other, over a pluggable transport. createCollaboration takes a user, a transport and onPeersChange and onRemoteEdit callbacks; broadcastChannelTransport syncs cursors and edits across browser tabs with no backend, and a WebSocket transport takes it across machines. Open the demo in two tabs to see it.

Presence (who's here + where their cursor is) and live edits (a change in one client lands in every other), over a pluggable transport.

```text
const collab = createCollaboration({
  user, transport: broadcastChannelTransport('my-grid'),
  onPeersChange: (peers) => ...,   // render avatars / cursors
  onRemoteEdit: ({ rowId, columnId, value }) => ...,  // apply it
})
```

OPEN THIS DEMO IN TWO TABS - edits and cursors sync live between them with zero backend (BroadcastChannel). Swap the transport for a WebSocket to go cross-machine.

## Imports, features and API used

- Imports: `@svgrid/grid`
- Columns: `task` (Task), `owner` (Owner), `status` (Status), `estimate` (Estimate)

## Frequently asked questions

### How do cursors get shared?

onActiveCellChange sends the row id and column id through the transport; onPeersChange delivers every peer's position, and an effect locates the matching cell by its data-svgrid-row and data-col-id attributes to draw a coloured box with the peer's name over it.

### How are remote edits applied?

onCellValueChange broadcasts { rowId, columnId, value }; the receiving client's onRemoteEdit writes the value into its own data by row id and the cell flashes green through a shared cellClass callback.

### What does a WebSocket transport need to implement?

The same send and subscribe shape as broadcastChannelTransport, relaying messages to every other client through your server. The collaboration object does not care which transport carries them.

## Related documentation

- [Real-time collaboration](https://svgrid.com/docs/help/collaboration.md)

## Related articles

- [Stable Row Identity in SvGrid (and why it matters)](https://svgrid.com/blog/stable-row-identity-getrowid/) - How SvGrid tracks rows across mutations, why object reference stability is the key to correct selection and editing, and the exact patterns to use with live data feeds.

## Source code (149-realtime-collaboration.svelte)

```svelte
<!-- Documented in: docs/help/collaboration.md -->
<script lang="ts">
  /**
   * 149. Real-time collaboration
   * ----------------------------
   * Presence (who's here + where their cursor is) and live edits (a change in
   * one client lands in every other), over a pluggable transport.
   *
   *   const collab = createCollaboration({
   *     user, transport: broadcastChannelTransport('my-grid'),
   *     onPeersChange: (peers) => ...,   // render avatars / cursors
   *     onRemoteEdit: ({ rowId, columnId, value }) => ...,  // apply it
   *   })
   *
   * OPEN THIS DEMO IN TWO TABS - edits and cursors sync live between them
   * with zero backend (BroadcastChannel). Swap the transport for a WebSocket
   * to go cross-machine.
   */
  import {
    SvGrid,
    createCollaboration,
    broadcastChannelTransport,
    tableFeatures,
    type GridColumns,
    type CellContext,
    type CollabPresence,
  } from '@svgrid/grid'

  const features = tableFeatures({})

  type Row = { id: string; task: string; owner: string; status: string; estimate: number }
  let data = $state<Row[]>([
    { id: 't1', task: 'Design tokens', owner: 'Ada', status: 'In progress', estimate: 5 },
    { id: 't2', task: 'Virtualizer', owner: 'Linus', status: 'Done', estimate: 8 },
    { id: 't3', task: 'Filter menu', owner: 'Grace', status: 'In progress', estimate: 3 },
    { id: 't4', task: 'Pivot engine', owner: 'Alan', status: 'Todo', estimate: 13 },
    { id: 't5', task: 'Export to Excel', owner: 'Margaret', status: 'Review', estimate: 5 },
    { id: 't6', task: 'A11y audit', owner: 'Donald', status: 'Todo', estimate: 8 },
  ])

  // Flash a cell green when a remote user edits it. `cellClass` is a per-
  // column option; this shared callback keys off the live `flashes` map.
  function flashClass(ctx: CellContext<Row>) {
    return flashes[`${ctx.row.id}:${ctx.column.id}`] ? 'collab-flash' : ''
  }

  const columns: GridColumns<Row> = [
    { field: 'task', header: 'Task', width: 200, editorType: 'text', cellClass: flashClass },
    { field: 'owner', header: 'Owner', width: 130, editorType: 'text', cellClass: flashClass },
    {
      field: 'status',
      header: 'Status',
      width: 150,
      editorType: 'list',
      editorOptions: ['Todo', 'In progress', 'Review', 'Done'],
      cellClass: flashClass,
    },
    { field: 'estimate', header: 'Estimate', width: 120, align: 'right', editorType: 'number', cellClass: flashClass },
  ]

  // A random identity per tab.
  const COLORS = ['#ef4444', '#2563eb', '#16a34a', '#f59e0b', '#8b5cf6', '#ec4899']
  const NAMES = ['Otter', 'Falcon', 'Bison', 'Lynx', 'Heron', 'Wolf']
  const pick = <T,>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
  const me = { id: Math.random().toString(36).slice(2), name: pick(NAMES), color: pick(COLORS) }

  let peers = $state<CollabPresence[]>([])
  let flashes = $state<Record<string, number>>({})
  let gridWrap = $state<HTMLElement | null>(null)

  const collab = createCollaboration({
    user: me,
    transport: broadcastChannelTransport('sv-grid-collab-demo'),
    onPeersChange: (p) => (peers = p),
    onRemoteEdit: ({ rowId, columnId, value }) => {
      const i = data.findIndex((r) => r.id === rowId)
      if (i < 0) return
      ;(data[i] as Record<string, unknown>)[columnId] = value
      const key = `${rowId}:${columnId}`
      flashes = { ...flashes, [key]: Date.now() }
      setTimeout(() => {
        const next = { ...flashes }
        delete next[key]
        flashes = next
      }, 1000)
    },
  })
  $effect(() => () => collab.dispose())

  // Recompute remote-cursor overlay boxes from the live cell DOM.
  let cursorBoxes = $state<Array<{ left: number; top: number; w: number; h: number; color: string; name: string }>>([])
  $effect(() => {
    void peers
    let raf = 0
    const recompute = () => {
      if (!gridWrap) return
      const wrap = gridWrap.getBoundingClientRect()
      const boxes: typeof cursorBoxes = []
      for (const p of peers) {
        if (!p.cell) continue
        const i = data.findIndex((r) => r.id === p.cell!.rowId)
        if (i < 0) continue
        const cell = gridWrap.querySelector<HTMLElement>(
          `[data-svgrid-row="${i}"][data-col-id="${p.cell.columnId}"]`,
        )
        if (!cell) continue
        const b = cell.getBoundingClientRect()
        boxes.push({ left: b.left - wrap.left, top: b.top - wrap.top, w: b.width, h: b.height, color: p.color, name: p.name })
      }
      cursorBoxes = boxes
      raf = requestAnimationFrame(recompute)
    }
    raf = requestAnimationFrame(recompute)
    return () => cancelAnimationFrame(raf)
  })
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
    <div class="flex items-center justify-between gap-3">
      <p class="text-sm font-semibold" style="color: var(--sg-fg);">
        Live presence + edits via <code>createCollaboration</code>
      </p>
      <div class="flex items-center gap-1.5">
        <span class="collab-avatar" style={`background:${me.color}`} title={`${me.name} (you)`}>{me.name[0]}</span>
        {#each peers as p (p.id)}
          <span class="collab-avatar" style={`background:${p.color}`} title={p.name}>{p.name[0]}</span>
        {/each}
        <span class="ml-2 text-xs" style="color: var(--sg-muted);">
          {peers.length + 1} here
        </span>
      </div>
    </div>
    <p class="mt-1 text-xs" style="color: var(--sg-muted);">
      You are <strong style={`color:${me.color}`}>{me.name}</strong>. Open this
      demo in a second tab - cursors and edits sync live (BroadcastChannel, no
      backend). Double-click a cell to edit.
    </p>
  </div>

  <div class="flex-1 min-h-0 relative" bind:this={gridWrap}>
    <SvGrid responsive={true}
      columnResize
      data={data}
      columns={columns}
      features={features}
      editable
      enableCellSelection
      selectionMode="cell"
      getRowId={(r) => r.id}
      rowHeight={38}
      containerHeight="100%"
      fitColumns={true}
      onActiveCellChange={(c) => {
        const row = data[c.rowIndex]
        collab.setCell(row ? { rowId: row.id, columnId: c.columnId } : null)
      }}
      onCellValueChange={(e) => {
        const row = data[e.rowIndex]
        if (row) collab.sendEdit(row.id, e.columnId, e.newValue)
      }}
    />
    {#each cursorBoxes as box (box.name + box.left + box.top)}
      <div
        class="collab-cursor"
        style={`left:${box.left}px; top:${box.top}px; width:${box.w}px; height:${box.h}px; --c:${box.color}`}
      >
        <span class="collab-cursor-tag" style={`background:${box.color}`}>{box.name}</span>
      </div>
    {/each}
  </div>
</section>

<style>
  .collab-avatar {
    width: 24px; height: 24px;
    border-radius: 50%;
    color: #fff;
    font-size: 12px; font-weight: 700;
    display: inline-flex; align-items: center; justify-content: center;
    box-shadow: 0 0 0 2px var(--sg-bg, #fff);
  }
  .collab-cursor {
    position: absolute;
    z-index: 15;
    pointer-events: none;
    border: 2px solid var(--c);
    border-radius: 3px;
    box-shadow: 0 0 0 1px var(--c);
  }
  .collab-cursor-tag {
    position: absolute;
    top: -16px; left: -2px;
    font-size: 10px; font-weight: 700;
    color: #fff;
    padding: 0 5px;
    border-radius: 3px;
    white-space: nowrap;
  }
  :global(.sv-grid-cell.collab-flash) {
    animation: collab-flash 1s ease-out;
  }
  @keyframes collab-flash {
    0% { background: rgba(34, 197, 94, 0.5) !important; }
    100% { background: transparent; }
  }
</style>
```

[View this example on GitHub](https://github.com/sv-grid/sv-grid/blob/main/examples/src/demos/149-realtime-collaboration.svelte)
