# Collaborative canvas

Build collaborative whiteboard with the room's shared state and capability-aware controls.

Import: @slices.sh/realtime/headless

Public parts: 32

## Installation

### 1. Install the service registry

services/index.ts, which `import "@/services"` loads. A provider setup writes the same file; skip this if your app has one.

```bash
npx shadcn@latest add "https://slices.sh/r/services.json"
```

### 2. Install the headless library

```bash
npm install @slices.sh/realtime@^0.1.6
```

### 3. Install the service

Install the provider configuration via shadcn or the slices CLI. Skip this step if your app already has the service configured. Provider commands and provisioning: https://slices.sh/docs/services/realtime.md

## Anatomy

### Composition

```text
Room.Root room="standup"
  Board.Root
    Board.Surface
      Board.Canvas
        Board.Shapes
          Board.Shape
        Board.Selection
        Board.TheirSelections
        Board.Marquee
        Board.Lasers
      Board.Cursors
        Board.Cursor
      Board.TextEditor
      Board.Empty
      Board.Summary
    Board.Tool tool="select"
    Board.Styling
      Board.Style choice="ink" value="yellow"
      Board.Size
      Board.SizePreview
      Board.Opacity
    Board.Selected
      Board.Delete
      Board.Arrange to="front"
    Board.Undo
    Board.Redo
    Board.Zoom to="in"
    Board.ZoomLevel
    Board.Minimap
    Board.Export
    Board.RulerToggle
    Board.Ruler edge="top"
    Board.Unit unit="px"
```

## Minimal composition

```tsx
"use client"

import "@/services"
import { Room, Board } from "@slices.sh/realtime/headless"

export function Example() {
  return (
    <Room.Root room="studio">
      <Board.Root>
        <Board.Tool tool="rect">Rectangle</Board.Tool>
        <Board.Tool tool="select">Select</Board.Tool>
        <Board.Surface>
          <Board.Canvas>
            <Board.Shapes>
              <Board.Shape />
            </Board.Shapes>
          </Board.Canvas>
        </Board.Surface>
        <Board.Undo>Undo</Board.Undo>
        <Board.Redo>Redo</Board.Redo>
        <Board.Export>Export</Board.Export>
      </Board.Root>
    </Room.Root>
  )
}

```

Configure realtime. Media, captions and moderation controls hide when the provider does not grant their capability.

## Complete API reference

### Board.Root

scope

A whiteboard in the room: an infinite surface of shapes everybody edits together, each
person with their own view, tool, selection and history.

`inks` says what each ink is on this screen and `outline` how a freehand line is shaped;
both are looks, so both come from the screen. → hooks/realtime/board-look.ts

Following somebody, from a face in a presence list or a `Room.Follow`, keeps their pointer in
the middle of this person's view until this person takes the view back. → ./board-surface.tsx

Import: @slices.sh/realtime/headless

Board.Root must be inside <Room.Root>.

```tsx
BoardRootProps

type BoardRootProps = RootProps &
  Partial<BoardLook> & {
    /**
     * The tools this screen offers: the board starts on one of them and its keys pick no
     * other, so no tool without a button can be in hand. Every tool when left out.
     */
    tools?: readonly BoardTool[]
  }
```

Source: @slices.sh/realtime/src/headless/board-root.tsx

### Board.Surface

content · Element: div

The board itself: where the pointer draws, pans and selects, and where the keyboard picks a
tool — only while it has focus, so a letter typed in a chat beside it is a letter.

The camera travels as `--board-x`, `--board-y` and `--board-zoom`, so a grid drawn in CSS
moves with the board without a render per frame; `data-state` names the tool, for a cursor.

A press, a wheel turn or Escape on it ends following somebody.

Import: @slices.sh/realtime/headless

Board.Surface must be inside <Board.Root>.

```tsx
PartProps<"div", { tool: string }>
```

Source: @slices.sh/realtime/src/headless/board-surface.tsx

### Board.Canvas

content · Element: svg

The drawing, in world units: an SVG whose one group the camera moves, so a pan is one
attribute changing rather than every shape redrawn.

Import: @slices.sh/realtime/headless

Board.Canvas must be inside <Board.Root>.

```tsx
PartProps<"svg">
```

Source: @slices.sh/realtime/src/headless/board-canvas.tsx

### Board.Shapes

scope

Every shape on the board, bottom first, each in its own scope.

Import: @slices.sh/realtime/headless

Board.Shapes must be inside <Board.Root>.

```tsx
BoardShapesProps

type BoardShapesProps = RootProps & { group?: ComponentType<{ children?: ReactNode }> }
```

Source: @slices.sh/realtime/src/headless/board-shapes.tsx

### Board.Shape

content · Element: g

Visual renderer — not purely headless.

One shape, drawn. `data-state` is its type, `data-active` while it is selected; what it
looks like is its own data and the screen's inks.

Import: @slices.sh/realtime/headless

Board.Shape must be inside <Board.Shapes>.

Board.Shape must be inside <Board.Root>.

```tsx
PartProps<"g", { type: string }>
```

Source: @slices.sh/realtime/src/headless/board-shape.tsx

### Board.Selection

content · Element: g

Visual renderer — not purely headless.

The box around what is selected, with a handle on each corner and edge and one above it
for turning — drawn in `currentColor` at the same size on screen however far the board is
zoomed. The handles carry `data-handle` (`rotate` for the one above), which is how a drag on
one resizes or turns rather than moves.

Import: @slices.sh/realtime/headless

Board.Selection must be inside <Board.Root>.

```tsx
PartProps<"g">
```

Source: @slices.sh/realtime/src/headless/board-selection.tsx

### Board.TheirSelections

content · Element: g

Visual renderer — not purely headless.

What everybody else has selected: a dashed outline in their colour, their name on its
corner — the same colour as their pointer, so a glance says whose hand is on what. Drawn
at the same size on screen however far the board is zoomed.

Import: @slices.sh/realtime/headless

Board.TheirSelections must be inside <Board.Root>.

Board.TheirSelections must be inside <Room.Root>.

```tsx
PartProps<"g">
```

Source: @slices.sh/realtime/src/headless/board-their-selections.tsx

### Board.Marquee

content · Element: rect

The box a drag on empty board is drawing, in `currentColor`, while it is drawn.

Import: @slices.sh/realtime/headless

Board.Marquee must be inside <Board.Root>.

```tsx
PartProps<"rect">
```

Source: @slices.sh/realtime/src/headless/board-marquee.tsx

### Board.Lasers

content · Element: g

Visual renderer — not purely headless.

Everybody's laser: a line that fades from its tail, in `currentColor`, gone a second after
the pointer stops. Nothing is written to the board.

Import: @slices.sh/realtime/headless

Board.Lasers must be inside <Board.Root>.

```tsx
PartProps<"g">
```

Source: @slices.sh/realtime/src/headless/board-lasers.tsx

### Board.Cursors

scope

Everybody else's pointer on the board, each in the scope of the peer it belongs to.

Import: @slices.sh/realtime/headless

Board.Cursors must be inside <Board.Root>.

Board.Cursors must be inside <Room.Root>.

```tsx
BoardCursorsProps

type BoardCursorsProps = RootProps & {
  /**
   * One pointer per person — whichever of their tabs moved last — and none from your own
   * other tabs, which are you. Off, every tab is a pointer of its own.
   */
  perPerson?: boolean
}
```

Source: @slices.sh/realtime/src/headless/board-cursors.tsx

### Board.Cursor

content · Element: div

One collaborator's pointer, placed on screen where it is on the board, with that person's
colour as `--peer` and the ink that reads on it as `--peer-ink`.

Just after somebody is picked to follow it says `data-state="pinged"` for a moment, for a
skin to ring.

It glides on the same spring as `Room.Cursor`, in board units, and the camera is applied
as it is drawn: a pan or a zoom carries every pointer at once, while only a pointer's own
samples move it along the board. → ./use-pointer-spring.ts

Import: @slices.sh/realtime/headless

Board.Cursor must be inside <Board.Cursors>.

Board.Cursor must be inside <Board.Root>.

```tsx
PartProps<"div">
```

Source: @slices.sh/realtime/src/headless/board-cursor.tsx

### Board.TextEditor

input · Element: textarea

Typing into a shape: a text area laid exactly over it, at its size on screen. Every
keystroke reaches the others as it is typed; leaving it — Escape, a click elsewhere —
records one step of history, and a text left empty is taken away rather than left as an
invisible shape somebody later cannot find.

Import: @slices.sh/realtime/headless

Board.TextEditor must be inside <Board.Root>.

```tsx
PartProps<"textarea">
```

Source: @slices.sh/realtime/src/headless/board-text-editor.tsx

### Board.Empty

gate

Nothing on the board yet: where a first hint goes.

Import: @slices.sh/realtime/headless

Board.Empty must be inside <Board.Root>.

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/board-empty.tsx

### Board.Summary

gate

What is on the board, in words, for somebody who cannot see it.

Import: @slices.sh/realtime/headless

Board.Summary must be inside <Board.Root>.

```tsx
GateProps<{ shapes: number; selected: number }>
```

Source: @slices.sh/realtime/src/headless/board-summary.tsx

### Board.Tool

action · Element: button

Picks one tool. `aria-pressed` and `data-active` while it is the one in hand.

Import: @slices.sh/realtime/headless

Board.Tool must be inside <Board.Root>.

```tsx
PartProps<"button", { active: boolean }> & { tool: Tool }
```

Source: @slices.sh/realtime/src/headless/board-tool.tsx

### Board.Styling

gate

A style would change something: a shape is selected, or the tool in hand leaves one.

Import: @slices.sh/realtime/headless

Board.Styling must be inside <Board.Root>.

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/board-styling.tsx

### Board.Style

action · Element: button

One value of one style — an ink, a size, a dash, a fill — as a button. `aria-pressed` while
it is the value in use; an ink carries its colour as `--ink`, for its swatch.

Import: @slices.sh/realtime/headless

Board.Style must be inside <Board.Root>.

```tsx
PartProps<"button", { active: boolean }> & { choice: K; value: ShapeStyle[K] }
```

Source: @slices.sh/realtime/src/headless/board-style.tsx

### Board.Size

content · Element: div

The size of the next stroke, or of the selected shapes, as a slider with one stop for each
size: arrows, Home and End step it, and pressing or dragging along it picks the nearest stop.

The position travels as `--size-index` and `--size-at`, so a screen can draw a thumb and a
filled track in CSS; the content function gets the stops, for drawing each one.

Import: @slices.sh/realtime/headless

```tsx
BoardSizeProps

type BoardSizeProps = PartProps<"div", BoardSizeState> & {
  /** What each size is called, for a screen reader to say as the slider's value. */
  labels?: Record<InkSize, string>
}

type BoardSizeState = { size: InkSize; index: number; at: string; stops: SizeStop[] }

type SizeStop = {
  size: InkSize
  /** Where it sits along the slider, as a percentage the screen can place it with. */
  at: string
  /** It is the size in use. */
  active: boolean
  /** It is the size in use or a smaller one, which is how far the slider is filled. */
  passed: boolean
}
```

Source: @slices.sh/realtime/src/headless/board-size.tsx

### Board.SizePreview

content · Element: span

What the size in use draws: its stroke width as `--size-dot`, in the ink in use as `--ink`.
A screen makes a dot of it, which grows as the slider moves up.

Import: @slices.sh/realtime/headless

Board.SizePreview must be inside <Board.Root>.

```tsx
PartProps<"span">
```

Source: @slices.sh/realtime/src/headless/board-size-preview.tsx

### Board.Opacity

input · Element: input

How opaque, from a tenth to whole, as a slider.

Import: @slices.sh/realtime/headless

```tsx
PartProps<"input">
```

Source: @slices.sh/realtime/src/headless/board-opacity.tsx

### Board.Selected

gate

Something is selected: what acts on a selection belongs here.

Import: @slices.sh/realtime/headless

Board.Selected must be inside <Board.Root>.

```tsx
GateProps<{ count: number }>
```

Source: @slices.sh/realtime/src/headless/board-selected.tsx

### Board.Delete

action · Element: button

Deletes what is selected, as one step you can undo.

Import: @slices.sh/realtime/headless

Board.Delete must be inside <Board.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/board-delete.tsx

### Board.Arrange

action · Element: button

Moves what is selected up or down the pile: `to` is `front`, `forward`, `backward` or
`back`. Unavailable with nothing selected. Everybody sees the new order, and it is not a
step of history — undo takes back what was drawn, not where it lies.

Import: @slices.sh/realtime/headless

Board.Arrange must be inside <Board.Root>.

```tsx
PartProps<"button"> & { to: Stacking }
```

Source: @slices.sh/realtime/src/headless/board-arrange.tsx

### Board.Undo

action · Element: button

Takes back the last thing you did on the board — never anybody else's.

Import: @slices.sh/realtime/headless

Board.Undo must be inside <Board.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/board-undo.tsx

### Board.Redo

action · Element: button

Does again what you last took back.

Import: @slices.sh/realtime/headless

Board.Redo must be inside <Board.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/board-redo.tsx

### Board.Zoom

action · Element: button

Zooms the view, about its middle.

Import: @slices.sh/realtime/headless

Board.Zoom must be inside <Board.Root>.

```tsx
ZoomProps

type ZoomProps = PartProps<"button"> & {
  /** In, out, the whole drawing, or back to actual size. */
  to: "in" | "out" | "fit" | "actual"
}
```

Source: @slices.sh/realtime/src/headless/board-zoom.tsx

### Board.ZoomLevel

content · Element: span

The zoom as a person reads it: "100%".

Import: @slices.sh/realtime/headless

Board.ZoomLevel must be inside <Board.Root>.

```tsx
PartProps<"span">
```

Source: @slices.sh/realtime/src/headless/board-zoom-level.tsx

### Board.Minimap

content · Element: svg

Visual renderer — not purely headless.

The whole board small, with the part you are looking at outlined: press or drag on it to go
somewhere else. Shapes are boxes in their own ink, which is all a map needs to be.

Import: @slices.sh/realtime/headless

Board.Minimap must be inside <Board.Root>.

```tsx
PartProps<"svg">
```

Source: @slices.sh/realtime/src/headless/board-minimap.tsx

### Board.Export

action · Element: button

Saves the drawing as an SVG file: every shape, framed, with the colours the page is showing
written into it — the inks are theme tokens, and a file opened elsewhere has no theme.

Import: @slices.sh/realtime/headless

Board.Export must be inside <Board.Root>.

```tsx
PartProps<"button"> & { name?: string }
```

Source: @slices.sh/realtime/src/headless/board-export.tsx

### Board.RulerToggle

action · Element: button

Rulers on or off. `aria-pressed` while they are shown.

Import: @slices.sh/realtime/headless

Board.RulerToggle must be inside <Board.Root>.

```tsx
PartProps<"button", { on: boolean }>
```

Source: @slices.sh/realtime/src/headless/board-ruler-toggle.tsx

### Board.Ruler

content · Element: svg

Visual renderer — not purely headless.

A ruler along one edge of the board, in the unit chosen, following the camera. Ticks and
numbers are `currentColor`; nothing while rulers are off.

Import: @slices.sh/realtime/headless

Board.Ruler must be inside <Board.Root>.

```tsx
PartProps<"svg"> & { edge: "top" | "left" }
```

Source: @slices.sh/realtime/src/headless/board-ruler.tsx

### Board.Unit

action · Element: button

Measure in one unit. `aria-pressed` while it is the one in use.

Import: @slices.sh/realtime/headless

Board.Unit must be inside <Board.Root>.

```tsx
PartProps<"button", { active: boolean }> & { unit: LengthUnit }
```

Source: @slices.sh/realtime/src/headless/board-unit.tsx

### Room.Root

scope

The room everything under here is in.

Joining happens in the provider's effect rather than here, so a component that renders
twice before it commits does not put two peers in the roster for one tab.

A root inside a root for the same room shares its connection, and its follow too: a
presence list placed on a canvas has to move the canvas when somebody on it is picked, and
two follows would be two lists of whom somebody is following. A root inside a root for the
same room needs no service of its own, and says nothing when there is none.

Optional with no service, or off, there is no room: its status is "off", so every gate stays
shut and `Room.Present` hides what the room is drawn inside, and its sink is switched off, so
a Composer inside renders nothing. → ../hooks/use-realtime-scope.ts

Import: @slices.sh/realtime/headless

```tsx
RoomRootProps

type RoomRootProps = RootProps & RealtimeRootProps & {
  room: string
  /**
   * Who this browser shows itself as. Without one, a visitor gets an animal of their own; and
   * somebody signed in is their session's name and picture whatever this says.
   */
  profile?: PeerProfile
  /**
   * One person across this browser's tabs: every tab takes the same name, the one a screen
   * that counts people shows once. Off, each tab is somebody of its own.
   */
  perPerson?: boolean
  /** Off, the transport never asks for a camera or a microphone. */
  media?: boolean
  /** Captions start shown; each person can still hide them for themselves. */
  captions?: boolean
}
```

Source: @slices.sh/realtime/src/headless/room-root.tsx
